fix(python): resolve calls through aliased package re-exports (#3504)

This commit is contained in:
azizur100389 2026-10-08 11:51:20 +01:00 • committed by GitHub
parent 4ccba12004
commit ff922c0a3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1427 additions and 170 deletions

View file

@ -70,6 +70,9 @@ export interface FinalizeFile {
* static input.
*/
readonly localDefs: readonly SymbolDefinition[];
/** Exact module bindings when the provider can distinguish them from nested
* declarations. Absent preserves the legacy definition-name lookup. */
readonly localExports?: ReadonlyMap<string, SymbolDefinition>;
/**
* The extractor's actual lexical bindings for {@link moduleScope}.
*
@ -134,6 +137,14 @@ export interface FinalizeHooks {
*/
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
/** Opt into wildcard expansion over local and transitively imported exports.
* The same filter governs closure propagation and binding materialization. */
readonly filterWildcardNames?: (
targetModuleScope: ScopeId,
availableNames: readonly string[],
workspaceIndex: WorkspaceIndex,
) => readonly string[];
/**
* Does this language make two `wildcard` re-exports that both DECLARE the
* same name AMBIGUOUS (no winner), rather than overloads or redeclarations
@ -317,6 +328,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
moduleEdgeIndex,
ambiguousByFile,
topLevelOnly,
hooks.filterWildcardNames,
input.workspaceIndex,
);
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
@ -404,7 +417,13 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
}
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
// Produce one `wildcard-expanded` ImportEdge per exported name.
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
const expanded = expandWildcard(
edge,
byFilePath,
hooks,
input.workspaceIndex,
reexportClosures,
);
for (const e of expanded) {
if (
d.fromScope === file.moduleScope &&
@ -506,6 +525,9 @@ function makeEdgeDrafts(
targetFile: null,
targetExportedName: extractExportedName(parsed),
kind: edgeKindFor(parsed),
...(parsed.kind === 'namespace' && parsed.explicitAlias === true
? { explicitAlias: true }
: {}),
...typeOnlyFor(parsed),
...runsOnlyWhenCalledFor(parsed),
linkStatus: 'unresolved',
@ -544,6 +566,9 @@ function makeEdgeDrafts(
hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true
? 'namespace'
: edgeKindFor(parsed),
...(parsed.kind === 'namespace' && parsed.explicitAlias === true
? { explicitAlias: true }
: {}),
...typeOnlyFor(parsed),
...runsOnlyWhenCalledFor(parsed),
};
@ -687,7 +712,7 @@ function tryFinalize(
// a failure.
if (draft.base.kind === 'namespace') {
const moduleDef = findExportByName(
targetModule.localDefs,
targetModule,
extractExportedName(draft.source),
topLevelOnly,
);
@ -702,7 +727,7 @@ function tryFinalize(
// local defs. Multi-hop re-export chains settle iteratively — each hop
// resolves once its prior hop is finalized.
const importedName = extractExportedName(draft.source);
const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
const exported = findExportByName(targetModule, importedName, topLevelOnly);
if (exported !== undefined) {
const transitiveVia =
@ -831,6 +856,8 @@ function buildReexportClosures(
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
ambiguous: ReadonlyMap<string, ReadonlySet<string>>,
topLevelOnly: boolean,
filterWildcardNames: FinalizeHooks['filterWildcardNames'],
workspace: WorkspaceIndex,
): ReadonlyMap<string, FileReexportClosure> {
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
for (const file of files) closures.set(file.filePath, new Map());
@ -866,7 +893,16 @@ function buildReexportClosures(
if (!scc.isCycle) {
const filePath = scc.files[0];
if (filePath !== undefined) {
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly);
populateFileClosure(
filePath,
byFilePath,
edgeIndex,
closures,
ambiguous,
topLevelOnly,
filterWildcardNames,
workspace,
);
}
continue;
}
@ -881,7 +917,16 @@ function buildReexportClosures(
iter++;
for (const filePath of scc.files) {
if (
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly)
populateFileClosure(
filePath,
byFilePath,
edgeIndex,
closures,
ambiguous,
topLevelOnly,
filterWildcardNames,
workspace,
)
) {
progressed = true;
}
@ -1021,7 +1066,7 @@ function namedReexportCandidates(
if (targetFile === null) continue;
const target = byFilePath.get(targetFile);
if (target === undefined) continue;
const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly);
const def = findExportByName(target, draft.source.importedName, topLevelOnly);
if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId);
}
return Object.freeze(ids);
@ -1216,6 +1261,8 @@ function populateFileClosure(
closures: Map<string, Map<string, ReexportClosureEntry>>,
ambiguousByFile: ReadonlyMap<string, ReadonlySet<string>>,
topLevelOnly: boolean,
filterWildcardNames: FinalizeHooks['filterWildcardNames'],
workspace: WorkspaceIndex,
): boolean {
const myClosure = closures.get(filePath);
if (myClosure === undefined) return false;
@ -1240,7 +1287,7 @@ function populateFileClosure(
if (ambiguous.has(localName) || myClosure.has(localName)) continue;
const importedName = draft.source.importedName;
const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
const direct = findExportByName(targetModule, importedName, topLevelOnly);
if (direct !== undefined) {
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
continue;
@ -1283,15 +1330,26 @@ function populateFileClosure(
// named-import path: only a language that opted in (ECMAScript, where
// `export *` cannot publish a class member) narrows; every other language's
// wildcard keeps the wide index, whose members are legitimately reachable.
for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(
targetModule.localDefs,
)) {
const localExports = localExportsByName(targetModule, topLevelOnly);
const targetClosure = closures.get(targetFile);
const allowedNames =
filterWildcardNames === undefined
? undefined
: new Set(
filterWildcardNames(
targetModule.moduleScope,
[...new Set([...localExports.keys(), ...(targetClosure?.keys() ?? [])])],
workspace,
),
);
for (const [name, def] of localExports) {
if (allowedNames !== undefined && !allowedNames.has(name)) continue;
if (ambiguous.has(name) || myClosure.has(name)) continue;
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
}
const targetClosure = closures.get(targetFile);
if (targetClosure !== undefined) {
for (const [name, entry] of targetClosure) {
if (allowedNames !== undefined && !allowedNames.has(name)) continue;
if (ambiguous.has(name) || myClosure.has(name)) continue;
myClosure.set(name, {
def: entry.def,
@ -1366,7 +1424,7 @@ function deriveSimpleName(def: SymbolDefinition): string | null {
}
function findExportByName(
defs: readonly SymbolDefinition[],
file: FinalizeFile,
name: string,
/**
* `true` (a `namedImportsBindTopLevelOnly` language): consult only
@ -1400,7 +1458,17 @@ function findExportByName(
//
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
// for the cross-file regression this rule prevents.
return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name);
return localExportsByName(file, topLevelOnly).get(name);
}
function localExportsByName(
file: FinalizeFile,
topLevelOnly: boolean,
): ReadonlyMap<string, SymbolDefinition> {
return (
file.localExports ??
(topLevelOnly ? indexTopLevelExportsByName(file.localDefs) : indexExportsByName(file.localDefs))
);
}
/**
@ -1531,6 +1599,7 @@ function expandWildcard(
byFilePath: Map<string, FinalizeFile>,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
): readonly ImportEdge[] {
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
return [edge]; // unresolvable wildcard survives as a single unlinked edge
@ -1538,7 +1607,14 @@ function expandWildcard(
const target = byFilePath.get(edge.targetFile);
if (target === undefined) return [edge];
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
const targetClosure =
hooks.filterWildcardNames === undefined ? undefined : reexportClosures.get(edge.targetFile);
const names =
hooks.filterWildcardNames?.(
edge.targetModuleScope,
[...new Set([...localExportsByName(target, false).keys(), ...(targetClosure?.keys() ?? [])])],
workspace,
) ?? hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
if (names.length === 0) {
// Resolved wildcard with zero propagating names is still a real file-
// level dependency (e.g. a C++ header that only declares classes —
@ -1551,8 +1627,10 @@ function expandWildcard(
}
const expanded: ImportEdge[] = [];
const localExports = localExportsByName(target, false);
for (const name of names) {
const def = findExportByName(target.localDefs, name);
const inherited = targetClosure?.get(name);
const def = localExports.get(name) ?? inherited?.def;
if (def === undefined) continue;
expanded.push({
localName: name,
@ -1561,6 +1639,9 @@ function expandWildcard(
kind: 'wildcard-expanded',
targetModuleScope: edge.targetModuleScope,
targetDefId: def.nodeId,
...(localExports.has(name) || inherited === undefined
? {}
: { transitiveVia: extendVia(edge.targetFile, inherited.via) }),
// Every expanded edge inherits the presence facts of the ONE statement it
// came from. They are built fresh rather than spread from `edge` because
// `localName`, `targetExportedName` and `targetDefId` all differ per name

View file

@ -305,6 +305,8 @@ type ParsedImportSyntax =
readonly kind: 'namespace';
/** The source omitted a local alias; the imported module declares its binding name. */
readonly implicitLocalName?: boolean;
/** An explicit alias binds the imported module, even when it matches the path's root. */
readonly explicitAlias?: boolean;
/** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */
readonly localName: string;
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */
@ -526,6 +528,8 @@ export interface Callsite {
export interface ImportEdge {
/** How this scope sees the imported name (after alias). */
readonly localName: string;
/** Namespace-import alias syntax, retained for providers whose unaliased imports bind a path prefix. */
readonly explicitAlias?: boolean;
/** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */
readonly targetFile: string | null;
/** The name under which the target exports this symbol. */

View file

@ -1 +1 @@
1ab17d9cb80c3863ac9df17e4eefe4983a21f8d9908c110e8a8d9fa1b35efcc0
b6754e5362716211a348f3aafda8bbc00082e3fb0e45cb3aa79a1be5e3df1a8b

View file

@ -47,12 +47,12 @@
* set moved, which is a behaviour change to be explained, never re-baselined to
* make CI green.
*
* WHY A ZIG CORPUS for a language-neutral pass. Zig is the only language whose
* provider sets `namespaceExportsIncludeImportedNames`, so it is the only one
* that can exercise channel 3 at all; and the file-as-struct idiom puts channels
* 2 and 4 in one file, which is the shape #3399 was filed over. The corpus also
* carries two DECLINE controls — a non-callable namespace member and a
* non-callable bare argument — so a change that widened the callable gate would
* WHY A ZIG CORPUS for a language-neutral pass. Python and Zig both opt into
* imported-name namespace lookup. Zig's hub re-exports exercise channel 3,
* and its file-as-struct idiom puts channels 2 and 4 in one file, which is the
* shape #3399 was filed over. The corpus also carries two DECLINE controls —
* a non-callable namespace member and a non-callable bare argument — so a
* change that widened the callable gate would
* move `declined` rather than hiding inside the timing.
*
* Container naming is load-bearing in the corpus: a Zig file-as-struct is minted

View file

@ -68,6 +68,8 @@ export interface FinalizeOrchestratorOptions {
* merge for bindings.
*/
readonly hooks?: Partial<FinalizeHooks>;
/** Optional exact module export surface, independent of nested definitions. */
readonly moduleExports?: (file: ParsedFile) => ReadonlyMap<string, SymbolDefinition>;
/**
* Opaque workspace context forwarded to hooks. `undefined` today; Ring
* 2 PKG #922 populates this with a real cross-file index for the
@ -95,7 +97,7 @@ export function finalizeScopeModel(
// materialization. Returns linked imports + merged bindings per module
// scope + SCC condensation + stats.
const finalizeInput = {
files: parsedFiles.map(toFinalizeFile),
files: parsedFiles.map((file) => toFinalizeFile(file, options.moduleExports?.(file))),
workspaceIndex,
};
const finalizeOut = finalize(finalizeInput, hooks);
@ -165,8 +167,11 @@ export function finalizeScopeModel(
// ─── Internal ───────────────────────────────────────────────────────────────
/** Project a `ParsedFile` into the narrower `FinalizeFile` shape. */
function toFinalizeFile(file: ParsedFile): FinalizeFile {
/** Shape-reduce parse output and attach the provider's exact export surface. */
function toFinalizeFile(
file: ParsedFile,
localExports?: ReadonlyMap<string, SymbolDefinition>,
): FinalizeFile {
const moduleScope = file.scopes.find((scope) => scope.id === file.moduleScope);
const moduleBindings = new Map(moduleScope?.bindings ?? []);
// Namespace members historically participate in the same-file unqualified
@ -183,6 +188,7 @@ function toFinalizeFile(file: ParsedFile): FinalizeFile {
moduleScope: file.moduleScope,
parsedImports: file.parsedImports,
localDefs: file.localDefs,
localExports,
// The extractor always emits the module scope. Its binding map is the
// authoritative lexical surface; `ownedDefs` is structural ownership and
// excludes hoisted top-level function/class declarations.
@ -220,6 +226,7 @@ function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true,
namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true,
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
filterWildcardNames: partial.filterWildcardNames,
mergeBindings:
partial.mergeBindings ??
((

View file

@ -0,0 +1,62 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
const namesByWorkspace = new WeakMap<
readonly ParsedFile[],
ReadonlyMap<ScopeId, readonly string[] | null>
>();
/** Keep each module binding's definition, rather than looking its name up again
* among class members and nested functions in the file's flat definition list. */
export function pythonModuleExports(parsed: ParsedFile): ReadonlyMap<string, SymbolDefinition> {
const exports = new Map<string, SymbolDefinition>();
const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope);
for (const [name, bindings] of module?.bindings ?? []) {
const definition = bindings[0]?.def;
if (
definition !== undefined &&
bindings.every((binding) => binding.def.nodeId === definition.nodeId)
) {
exports.set(name, definition);
}
}
return exports;
}
/** Public declarations imported by `from module import *`.
* Explicit `__all__` values are not represented by ParsedFile. Decline those
* modules rather than assume that every public declaration was exported.
*/
export function expandPythonWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
availableNames?: readonly string[],
): readonly string[] {
let byScope = namesByWorkspace.get(parsedFiles);
if (byScope === undefined) {
const collected = new Map<ScopeId, readonly string[] | null>();
for (const parsed of parsedFiles) {
const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope);
// Scope-creating declarations are owned by their body scope, but bind
// in the parent. The parsed module's binding keys preserve that rule.
const names = [...(module?.bindings.keys() ?? [])];
const hasExplicitExports =
names.includes('__all__') ||
parsed.parsedImports.some(
(edge) =>
'localName' in edge &&
edge.localName === '__all__' &&
(edge.declaredAtScope === undefined || edge.declaredAtScope === parsed.moduleScope),
);
collected.set(parsed.moduleScope, hasExplicitExports ? null : names);
}
byScope = collected;
namesByWorkspace.set(parsedFiles, byScope);
}
const localNames = byScope.get(targetModuleScope);
if (localNames == null) return [];
return [
...new Set(
(availableNames ?? localNames).filter((name) => name.length > 0 && !name.startsWith('_')),
),
];
}

View file

@ -464,11 +464,12 @@ export function isPythonImportedModule(
* `a.helper()` resolved into `a/b/c.py` whenever that module happened to export
* `helper`, and `a.b.mid()` resolved to nothing.
*
* Returns `undefined` — meaning "use the shared default" — for every spelling
* where the bound name is not the path's root:
* Returns `undefined` — meaning "use the shared default" — for aliases and
* spellings where the bound name is not the path's root:
* - `import single` — no dotted path to expand;
* - `import a.b as x` — binds only `x`; writing `a.b.f()` there is a
* NameError, so `a.b` must NOT become a key;
* - `import a.b as a` — also binds the leaf, despite the root spelling;
* - `from pkg import db` — reclassified to a namespace edge whose
* importPath is the bare name `db`.
*
@ -477,9 +478,15 @@ export function isPythonImportedModule(
* `__init__.py`) contributes no key rather than one pointing at a missing file.
*/
export function pythonNamespaceReceiverPaths(
edge: { readonly localName: string; readonly importPath: string; readonly targetFile: string },
edge: {
readonly localName: string;
readonly importPath: string;
readonly targetFile: string;
readonly explicitAlias?: boolean;
},
moduleFileExists: (filePath: string) => boolean,
): readonly (readonly [string, string])[] | undefined {
if (edge.explicitAlias === true) return undefined;
const segments = edge.importPath.split('.');
if (segments.length < 2) return undefined;
if (segments[0] !== edge.localName) return undefined;
@ -498,26 +505,37 @@ export function pythonNamespaceReceiverPaths(
// POSIX-vs-Windows probing is needed: workspace paths are not normalized at
// ingestion, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`.
const dirs = edge.targetFile.split('/').slice(0, -1);
// The import's leading segments name the leaf's innermost directories.
const offset = dirs.length - (segments.length - 1);
// A package's final segment names its directory; a module's names its file.
const importedDirectoryCount = edge.targetFile.endsWith('/__init__.py')
? segments.length
: segments.length - 1;
const offset = dirs.length - importedDirectoryCount;
if (offset < 0) return out;
for (let i = 1; i < segments.length; i++) {
const spelling = segments.slice(0, i).join('.');
const packageFile = dirs.slice(0, offset + i).join('/') + '/__init__.py';
// Package FIRST, then the leaf as a fallback — order is the whole point.
//
// `findExportedDef` only accepts a binding whose `origin === 'local'`, and
// the canonical package re-exports (`from .b.c import helper` in
// `__init__.py`) produce an IMPORT binding. Keying the prefix at the
// package alone therefore loses `a.helper()` entirely for the most common
// package shape — the fixtures here all define members locally in
// `__init__.py`, which is precisely the one layout where that mistake is
// invisible. Keeping the leaf behind the package restores that resolution
// while still letting a real definition in `__init__.py` win over a
// same-named decoy deeper in the package.
// Imported members resolve through the package's own bindings. The leaf
// must not publish its members under an intermediate package's name.
if (moduleFileExists(packageFile)) out.push([spelling, packageFile]);
out.push([spelling, edge.targetFile]);
}
return out;
}
/** Unaliased dotted imports bind their root package object, including namespace packages
* without an __init__.py. Anchor identity on the resolved path, not spelling. */
export function pythonNamespaceBindingIdentity(edge: {
readonly localName: string;
readonly importPath: string;
readonly targetFile: string;
readonly explicitAlias?: boolean;
}): string | undefined {
if (edge.explicitAlias === true) return edge.targetFile;
const segments = edge.importPath.split('.');
if (segments.length < 2 || segments[0] !== edge.localName) return edge.targetFile;
const dirs = edge.targetFile.split('/').slice(0, -1);
const count = edge.targetFile.endsWith('/__init__.py') ? segments.length : segments.length - 1;
const offset = dirs.length - count;
if (offset < 0) return undefined;
return dirs.slice(0, offset + 1).join('/') + '/__init__.py';
}

View file

@ -77,6 +77,7 @@ export { pythonArityCompatibility } from './arity.js';
export {
isPythonImportedModule,
pythonNamespaceReceiverPaths,
pythonNamespaceBindingIdentity,
resolvePythonImportTarget,
type PythonResolveContext,
} from './import-target.js';

View file

@ -54,6 +54,7 @@ export function interpretPythonImport(captures: CaptureMatch): ParsedImport | nu
return {
kind: 'namespace',
localName: aliasCap.text,
explicitAlias: true,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};

View file

@ -22,9 +22,11 @@ import type {
} from '../../scope-resolution/contract/scope-resolver.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import { pythonProvider } from '../python.js';
import { expandPythonWildcardNames, pythonModuleExports } from './expand-wildcards.js';
import {
isPythonImportedModule,
pythonNamespaceReceiverPaths,
pythonNamespaceBindingIdentity,
pythonArityCompatibility,
pythonMergeBindings,
resolvePythonImportTarget,
@ -77,6 +79,13 @@ const pythonScopeResolver: ScopeResolver = {
// A free call naming a class constructs it: `Service(db).do_work()` (#2708).
constructionSyntax: { bare: true },
language: SupportedLanguages.Python,
// Imported names on a package's __init__.py are addressable as module members.
namespaceExportsIncludeImportedNames: true,
importsBindAtLexicalScope: true,
moduleExports: pythonModuleExports,
filterWildcardNames: (scope, names, files) => expandPythonWildcardNames(scope, files, names),
namespaceBindingIdentity: pythonNamespaceBindingIdentity,
namespaceSkipsEnclosingClasses: true,
ownedMembersBindAtModuleScope: false,
suppressReceiverLookup: (typeRef) => typeRef.source === 'decorator-unknown',
languageProvider: pythonProvider,

View file

@ -452,6 +452,17 @@ export interface ScopeResolver {
parsedFiles: readonly ParsedFile[],
) => readonly string[];
/** Exact local module bindings, excluding definitions owned by nested scopes. */
readonly moduleExports?: (file: ParsedFile) => ReadonlyMap<string, SymbolDefinition>;
/** Filter local and transitive export names for wildcard imports. Opting in
* applies the filter both to re-export closures and imported bindings. */
readonly filterWildcardNames?: (
targetModuleScope: ScopeId,
availableNames: readonly string[],
parsedFiles: readonly ParsedFile[],
) => readonly string[];
/**
* Optional one-shot loader for cross-file import-resolution config
* (e.g. tsconfig path aliases for TypeScript, go.mod paths for Go,
@ -1395,10 +1406,27 @@ export interface ScopeResolver {
readonly localName: string;
readonly importPath: string;
readonly targetFile: string;
readonly explicitAlias?: boolean;
},
moduleFileExists: (filePath: string) => boolean,
) => readonly (readonly [spelling: string, targetFile: string])[] | undefined;
/** Stable identity of the namespace object bound by an import. Equal identities
* allow distinct receiver paths to remain visible across lexical scopes.
* Supplying this hook also suppresses same-scope import names with conflicting
* or unprovably equal identities; a lone namespace with undefined identity
* remains eligible. Without the hook, same-scope multi-target behavior stays. */
readonly namespaceBindingIdentity?: (edge: {
readonly localName: string;
readonly importPath: string;
readonly targetFile: string;
readonly explicitAlias?: boolean;
}) => string | undefined;
/** Enclosing class bodies are not lexical environments for namespace lookup.
* The reference's own class scope remains visible for class-body expressions. */
readonly namespaceSkipsEnclosingClasses?: boolean;
/**
* Optional language-specific member-lattice lookup. Runs for a resolved
* simple receiver type before the generic flattened-MRO walk. Languages

View file

@ -132,6 +132,7 @@ interface ResolveCompoundReceiverOptions {
/** A namespace member may be a name the target module imported and
* publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */
readonly namespaceExportsIncludeImportedNames?: boolean;
readonly namespaceSkipsEnclosingClasses?: boolean;
/** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`,
* `mod.Outer.Inner`) through the language's namespace chain walk
* (`ScopeResolver.resolveNamespaceChains`). Seeds the dotted-chain walk
@ -254,7 +255,15 @@ function resolveConstructionExpressionClass(
// A verified namespace is authoritative: do not fall through to the
// workspace-wide simple-name heuristics on either a miss or ambiguity.
if (namespaceFiles.length > 0) {
if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined;
if (
isNamespaceNameShadowed(
namespaceName,
inScope,
scopes,
options.namespaceSkipsEnclosingClasses,
)
)
return undefined;
const namespaceMatches = namespaceFiles
.map((targetFile) =>
options.namespaceExportsIncludeImportedNames === true

View file

@ -58,6 +58,10 @@ import {
isOwnerNameShadowedBySomethingElse,
lookupBindingsAt,
} from '../scope/walkers.js';
import {
collectNamespaceTargets,
type NamespaceTargetOptions,
} from '../scope/namespace-targets.js';
import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import { CALL_TARGET_TYPES } from '../../model/symbol-table.js';
@ -151,6 +155,7 @@ export function resolveValueRefTarget(
scopes: ScopeResolutionIndexes,
model: SemanticModel,
publishesImportedNames: boolean,
namespaceOptions?: NamespaceTargetOptions,
): SymbolDefinition | undefined {
const receiverName = site.explicitReceiver?.name;
if (receiverName === undefined) {
@ -178,6 +183,7 @@ export function resolveValueRefTarget(
receiverName,
scopes,
publishesImportedNames,
namespaceOptions,
);
if (viaNamespace !== undefined) {
// `'owned'` is NOT "no answer" — it is "this receiver is a namespace handle
@ -271,17 +277,25 @@ function findNamespaceValueRefTarget(
receiverName: string,
scopes: ScopeResolutionIndexes,
publishesImportedNames: boolean,
namespaceOptions?: NamespaceTargetOptions,
): SymbolDefinition | 'owned' | undefined {
const moduleScopeId = scopes.moduleScopes.get(filePath);
if (moduleScopeId === undefined) return undefined;
const targetFiles: string[] = [];
for (const edge of scopes.imports.get(moduleScopeId) ?? []) {
if (edge.kind !== 'namespace' || edge.localName !== receiverName) continue;
if (edge.targetFile === null) continue;
if (!targetFiles.includes(edge.targetFile)) targetFiles.push(edge.targetFile);
}
const targetFiles =
collectNamespaceTargets({ moduleScope: moduleScopeId }, scopes, {
...namespaceOptions,
inScope: site.inScope,
}).get(receiverName) ?? [];
if (targetFiles.length === 0) return undefined;
if (isNamespaceNameShadowed(receiverName, site.inScope, scopes)) return undefined;
if (
isNamespaceNameShadowed(
receiverName,
site.inScope,
scopes,
namespaceOptions?.skipEnclosingClasses,
)
)
return undefined;
/** The unique callable `select` finds across every target file, or nothing. */
const uniqueMember = (
@ -320,12 +334,11 @@ function findNamespaceValueRefTarget(
// imported names for a name the file declares. Same rule here, so `x.f` and
// `x.f()` cannot disagree about which module owns the name.
//
// Not reachable through valid Zig today — a container cannot declare a name
// twice, so one target file cannot hold both spellings, and Zig is the only
// provider that sets `namespaceExportsIncludeImportedNames`. It becomes
// reachable the moment a second provider opts in, or a receiver binds more
// than one target file; the guard is one `some` and the alternative failure
// is a confident edge into the wrong module.
// Providers that set `namespaceExportsIncludeImportedNames` can expose local
// and published bindings under one name; a receiver can also bind more than
// one target file. In either case, a local declaration owns the name even
// when it is not callable. The guard is one `some` and the alternative
// failure is a confident edge into the wrong module.
const declaredLocally = targetFiles.some((targetFile) => {
const targetScopeId = scopes.moduleScopes.get(targetFile);
return targetScopeId !== undefined && localRefs(targetScopeId).length > 0;
@ -339,7 +352,11 @@ function findNamespaceValueRefTarget(
// does for the CALL form.
const published = uniqueMember((scope) =>
lookupBindingsAt(scope, site.name, scopes).filter(
(ref) => ref.origin === 'import' || ref.origin === 'namespace' || ref.origin === 'reexport',
(ref) =>
ref.origin === 'import' ||
ref.origin === 'namespace' ||
ref.origin === 'reexport' ||
ref.origin === 'wildcard',
),
);
return published === 'ambiguous' || published === undefined ? 'owned' : published;
@ -359,6 +376,7 @@ export function emitPropertyDispatchCalls(
* so the CALL and the REGISTRATION forms of `hub.fn` cannot disagree.
*/
publishesImportedNames = false,
namespaceOptions?: NamespaceTargetOptions,
): {
usesEmitted: number;
callsEmitted: number;
@ -380,6 +398,7 @@ export function emitPropertyDispatchCalls(
scopes,
model,
publishesImportedNames,
namespaceOptions,
);
if (def === undefined) continue;

View file

@ -184,6 +184,8 @@ type ReceiverBoundProviderSubset = Pick<
| 'stripTypePreservingDecoration'
| 'resolveQualifiedReceiverMember'
| 'namespaceReceiverPaths'
| 'namespaceBindingIdentity'
| 'namespaceSkipsEnclosingClasses'
| 'resolveReceiverMember'
| 'suppressReceiverLookup'
| 'resolveThisViaEnclosingClass'
@ -197,6 +199,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'normalizeTypeArgument'
| 'markConstructionSites'
| 'namespaceExportsIncludeImportedNames'
| 'importsBindAtLexicalScope'
| 'resolveNamespaceChains'
>;
@ -504,7 +507,8 @@ export function emitReceiverBoundCalls(
const key = segments.slice(0, k).join('.');
const files = namespaceTargets.get(key);
if (files === undefined) continue;
if (isNamespaceNameShadowed(key, inScope, scopes)) return undefined;
if (isNamespaceNameShadowed(key, inScope, scopes, provider.namespaceSkipsEnclosingClasses))
return undefined;
cursor = { files };
rest = segments.slice(k);
break;
@ -558,13 +562,18 @@ export function emitReceiverBoundCalls(
: uniqueClassAcross(cursor.files, tail);
}
const files = namespaceTargets.get(head);
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
if (
files === undefined ||
isNamespaceNameShadowed(head, inScope, scopes, provider.namespaceSkipsEnclosingClasses)
)
return undefined;
return uniqueClassAcross(files, tail);
};
const compoundOpts = {
fieldFallback,
elementTypeOf: provider.elementTypeOf,
namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true,
namespaceSkipsEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
constructionSyntax: provider.constructionSyntax,
@ -1033,20 +1042,30 @@ export function emitReceiverBoundCalls(
};
for (const parsed of parsedFiles) {
const namespaceTargets = collectNamespaceTargets(parsed, scopes, {
receiverPaths: provider.namespaceReceiverPaths,
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
});
const fileCompoundOpts = {
...compoundOpts,
namespaceTargets,
...(walkChains
? {
resolveQualifiedClass: (qualifiedName: string, inScope: ScopeId) =>
resolveNamespaceQualifiedClass(qualifiedName, inScope, namespaceTargets),
}
: {}),
const namespaceContext = (inScope?: ScopeId) => {
const namespaceTargets = collectNamespaceTargets(parsed, scopes, {
receiverPaths: provider.namespaceReceiverPaths,
bindingIdentity: provider.namespaceBindingIdentity,
skipEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
inScope,
});
return {
namespaceTargets,
fileCompoundOpts: {
...compoundOpts,
namespaceTargets,
...(walkChains
? {
resolveQualifiedClass: (qualifiedName: string, scopeId: ScopeId) =>
resolveNamespaceQualifiedClass(qualifiedName, scopeId, namespaceTargets),
}
: {}),
},
};
};
const fileNamespaces = namespaceContext();
const namespacesByScope = new Map<ScopeId, ReturnType<typeof namespaceContext>>();
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
// capture is a no-op and emission stays byte-identical (R4).
@ -1059,6 +1078,17 @@ export function emitReceiverBoundCalls(
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
if (site.explicitReceiver === undefined) continue;
let namespaces = fileNamespaces;
if (provider.importsBindAtLexicalScope === true) {
let scoped = namespacesByScope.get(site.inScope);
if (scoped === undefined) {
scoped = namespaceContext(site.inScope);
namespacesByScope.set(site.inScope, scoped);
}
namespaces = scoped;
}
const { namespaceTargets, fileCompoundOpts } = namespaces;
const receiverName = site.explicitReceiver.name;
const memberName = site.name;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
@ -1544,7 +1574,7 @@ export function emitReceiverBoundCalls(
}
// ── Case 1: namespace receiver ───────────────────────────────
// `namespaceTargets` is collected per FILE, so a local declaration that
// `namespaceTargets` contains imports, so a local declaration that
// shadows the import must suppress it — `def f(pkg): pkg.db.query()`
// calls a method on the PARAMETER, and resolving it through the import
// emits a wrong edge, not a missing one. The compound-receiver
@ -1559,7 +1589,12 @@ export function emitReceiverBoundCalls(
const namespaceCandidates = namespaceTargets.get(receiverName);
let targetFiles: readonly string[] | undefined =
namespaceCandidates !== undefined &&
!isNamespaceNameShadowed(receiverName, site.inScope, scopes)
!isNamespaceNameShadowed(
receiverName,
site.inScope,
scopes,
provider.namespaceSkipsEnclosingClasses,
)
? namespaceCandidates
: undefined;
// Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is

View file

@ -819,7 +819,9 @@ export function runScopeResolution(
};
}
}
const filterWildcardNames = provider.filterWildcardNames;
const finalized = finalizeScopeModel(parsedFiles, {
moduleExports: provider.moduleExports,
hooks: {
ownedMembersBindAtModuleScope: provider.ownedMembersBindAtModuleScope,
importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true,
@ -832,6 +834,11 @@ export function runScopeResolution(
provider.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false,
expandsWildcardTo: (targetModuleScope) =>
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
filterWildcardNames:
filterWildcardNames === undefined
? undefined
: (targetModuleScope, names) =>
filterWildcardNames(targetModuleScope, names, parsedFiles),
mergeBindings: (existing, incoming, scopeId) =>
provider.mergeBindings(existing, incoming, scopeId),
wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true,
@ -1412,6 +1419,12 @@ export function runScopeResolution(
// member (Case 1). Without it a hub module's re-exported callable
// resolves when CALLED and declines when REGISTERED.
provider.namespaceExportsIncludeImportedNames === true,
{
receiverPaths: provider.namespaceReceiverPaths,
bindingIdentity: provider.namespaceBindingIdentity,
skipEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
moduleFileExists: (filePath) => indexes.moduleScopes.get(filePath) !== undefined,
},
);
if (propertyDispatch.skippedKeys > 0) {
// Never drop dispatch coverage silently: a hook table larger than the

View file

@ -1,6 +1,6 @@
/**
* Build a per-file `localName → targetFilePath` map over the file's
* module-scope namespace-kind import edges.
* Build a `localName → targetFilePath` map over visible namespace imports.
* Defaults to module scope; callers can opt into lexical scope traversal.
*
* Namespace imports (`import X`, `import X as Y`) bind a name that can
* appear as a receiver in member calls (`X.foo()`, `Y.foo()`). Named
@ -29,29 +29,24 @@
* `import Foo.Bar`, where the same pair means the opposite thing — see the
* hook's contract note.
*
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
* documents that function-local and class-body imports bind to the
* inner scope, which would make a module-only read incomplete. In
* practice `finalize-algorithm` places ALL of a file's ImportEdges
* onto `indexes.imports[moduleScope]` regardless of where the
* `import` statement appears — the integration fixtures
* `python-function-local-namespace-import` and
* `python-class-body-namespace-import` both emit correct CALLS edges
* with reason "namespace-receiver", demonstrating that the module-
* scope read is sufficient today. If finalize routing ever changes to
* honor the hook's per-scope contract, this function must walk the
* reference-site scope chain (mirror `findExportedDefByName`).
* Lexically scoped imports are collected from the reference's scope outward.
* The nearest scope owns each import name. Providers may retain outer receiver
* paths that refer to that same namespace object; sibling scopes are invisible.
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
export interface NamespaceTargetOptions {
/** Reference scope for providers that bind imports lexically. */
readonly inScope?: ScopeId;
/** `ScopeResolver.namespaceReceiverPaths` for the file's language. Absent
* (or returning `undefined` per edge) keeps the local-name-only default:
* the extra spellings are opt-in, never inferred from the edge shape. */
readonly receiverPaths?: ScopeResolver['namespaceReceiverPaths'];
readonly bindingIdentity?: ScopeResolver['namespaceBindingIdentity'];
readonly skipEnclosingClasses?: boolean;
/** Whether a path is a module the workspace parsed. Lets a provider propose
* a prefix file and have it dropped when absent, instead of minting a key
* to a file that does not exist. Defaults to "nothing exists". */
@ -59,13 +54,25 @@ export interface NamespaceTargetOptions {
}
export function collectNamespaceTargets(
parsed: ParsedFile,
parsed: Pick<ParsedFile, 'moduleScope'>,
scopes: ScopeResolutionIndexes,
options?: NamespaceTargetOptions,
): Map<string, string[]> {
const out = new Map<string, string[]>();
const moduleEdges = scopes.imports.get(parsed.moduleScope);
if (moduleEdges === undefined) return out;
const scopeIds: ScopeId[] = [];
let scopeId: ScopeId | null = options?.inScope ?? parsed.moduleScope;
const visited = new Set<ScopeId>();
while (scopeId !== null && !visited.has(scopeId)) {
visited.add(scopeId);
const scope = scopeId === parsed.moduleScope ? undefined : scopes.scopeTree.getScope(scopeId);
if (
!(options?.skipEnclosingClasses && scope?.kind === 'Class' && scopeId !== options.inScope)
) {
scopeIds.push(scopeId);
}
if (scopeId === parsed.moduleScope) break;
scopeId = scope?.parent ?? null;
}
const addTarget = (key: string, targetFile: string): void => {
let targets = out.get(key);
@ -78,25 +85,48 @@ export function collectNamespaceTargets(
const moduleFileExists = options?.moduleFileExists ?? ((): boolean => false);
for (const edge of moduleEdges) {
if (edge.targetFile === null || edge.kind !== 'namespace') continue;
const claimedNames = new Map<string, string | undefined>();
for (const id of scopeIds) {
const edges = scopes.imports.get(id) ?? [];
const namesHere = new Map<string, string | undefined>();
const conflictingNames = new Set<string>();
const candidates = edges.map((edge) => {
const namespace =
edge.targetFile !== null && edge.kind === 'namespace' && edge.linkStatus !== 'unresolved'
? {
localName: edge.localName,
importPath: edge.targetExportedName,
targetFile: edge.targetFile,
explicitAlias: edge.explicitAlias,
}
: undefined;
const identity = namespace === undefined ? undefined : options?.bindingIdentity?.(namespace);
if (!namesHere.has(edge.localName)) namesHere.set(edge.localName, identity);
else if (identity === undefined || namesHere.get(edge.localName) !== identity) {
namesHere.set(edge.localName, undefined);
conflictingNames.add(edge.localName);
}
return { edge, namespace, identity };
});
// Resolve ownership before emitting: import order alone cannot distinguish
// rebinding from conditional alternatives. Providers without an identity
// hook retain their existing multi-target behavior within a scope.
for (const { edge, namespace, identity } of candidates) {
if (options?.bindingIdentity !== undefined && conflictingNames.has(edge.localName)) continue;
if (
claimedNames.has(edge.localName) &&
(identity === undefined || claimedNames.get(edge.localName) !== identity)
)
continue;
if (namespace === undefined) continue;
const spellings = options?.receiverPaths?.(
{
localName: edge.localName,
importPath: edge.targetExportedName,
targetFile: edge.targetFile,
},
moduleFileExists,
);
// A provider that declines this edge — or has no hook — gets the default:
// the bound name alone, pointing at this edge's own target.
if (spellings === undefined) {
addTarget(edge.localName, edge.targetFile);
continue;
const spellings = options?.receiverPaths?.(namespace, moduleFileExists);
if (spellings === undefined) addTarget(edge.localName, namespace.targetFile);
else for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile);
}
for (const [name, identity] of namesHere) {
if (!claimedNames.has(name)) claimedNames.set(name, identity);
}
for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile);
}
return out;
}

View file

@ -316,6 +316,7 @@ export function isNamespaceNameShadowed(
namespaceName: string,
inScope: ScopeId,
scopes: ScopeResolutionIndexes,
skipEnclosingClasses = false,
): boolean {
const firstDot = namespaceName.indexOf('.');
const rootName = firstDot === -1 ? namespaceName : namespaceName.slice(0, firstDot);
@ -326,6 +327,10 @@ export function isNamespaceNameShadowed(
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return true;
if (skipEnclosingClasses && scope.kind === 'Class' && currentId !== inScope) {
currentId = scope.parent;
continue;
}
// Stop AT the module scope without inspecting it. In languages where a
// namespace import IS a variable declaration — CommonJS
// `const svc = require('./svc')` — the import puts its own name into the
@ -2294,7 +2299,12 @@ export function findExportedDefIncludingImportedNames(
if (moduleScope === undefined) return undefined;
let picked: SymbolDefinition | undefined;
for (const ref of lookupBindingsAt(moduleScope.id, memberName, scopes)) {
if (ref.origin !== 'import' && ref.origin !== 'namespace' && ref.origin !== 'reexport')
if (
ref.origin !== 'import' &&
ref.origin !== 'namespace' &&
ref.origin !== 'reexport' &&
ref.origin !== 'wildcard'
)
continue;
if (picked === undefined) {
picked = ref.def;

View file

@ -835,7 +835,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v130 (#3502): Python globals use exact function/class block ownership and
// Ruby ordinary-method binding uses AST markers. Warm v129 ParsedFiles retain
// incorrect declaration scopes and must be re-extracted.
const SCHEMA_BUMP = 130;
// v131 (#3504): Python namespace imports retain explicit alias syntax. Warm
// v130 ParsedFiles lack this fact and can bind a root-spelled alias to the
// package root instead of the imported module.
const SCHEMA_BUMP = 131;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,4 @@
from .services import bp as backplane
def combo_caller():
return backplane.pf()

View file

@ -0,0 +1,4 @@
from .services.bp import pf
def reexport_pkg_caller():
return pf()

View file

@ -0,0 +1,5 @@
from .gp import (
pf,
)
__all__ = ["pf"]

View file

@ -0,0 +1,2 @@
def pf():
return 1

View file

@ -19,6 +19,30 @@
"captureGroups": 13,
"digest": "cec30bb7577edfb751bcd4f5026a0c9d77ed235071421a2a46f87580c7bcdf47"
},
"python-aliased-package-reexport/app/__init__.py": {
"captureGroups": 1,
"digest": "532aee1a4b67f99b8d843a235afd851cdab275de0b6cb02dbdcf6f7670f264aa"
},
"python-aliased-package-reexport/app/combo.py": {
"captureGroups": 5,
"digest": "1c33bce592afba5a30b510ca69204b62e03b44c66716cae5cde318b348b909fe"
},
"python-aliased-package-reexport/app/reex.py": {
"captureGroups": 5,
"digest": "f55df8dd034b73c8d2c45a3eb97fb4244ec37bf6be28a78586627ba76ec419e6"
},
"python-aliased-package-reexport/app/services/__init__.py": {
"captureGroups": 1,
"digest": "532aee1a4b67f99b8d843a235afd851cdab275de0b6cb02dbdcf6f7670f264aa"
},
"python-aliased-package-reexport/app/services/bp/__init__.py": {
"captureGroups": 3,
"digest": "7cd93974ed3d3d31451ce9e5e9bb0dd68f6a015f24e865658045d50d0c031fc7"
},
"python-aliased-package-reexport/app/services/bp/gp.py": {
"captureGroups": 3,
"digest": "89564bb524c12295365d65a304418c519b4910f29c53cfe2b755bc55a5d25474"
},
"python-ambiguous/models/__init__.py": {
"captureGroups": 0,
"digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

View file

@ -0,0 +1,314 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
describe('Python wildcard definitions and lexical namespace visibility', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-python-namespace-semantics-'));
writeFixtureRepo(repoDir, {
'definitions.py':
'class Worker:\n def run(self):\n return 1\ndef run():\n return 2\n',
'values.py': 'run = 42\nclass Worker:\n def run(self):\n return 1\n',
'definitions_star.py': 'from definitions import *\n',
'values_star.py': 'from values import *\n',
'origin.py':
'def helper():\n return 1\ndef _hidden():\n return 2\nclass Maker:\n def work(self):\n return 3\n',
'pkg/__init__.py': 'from origin import helper as exported, Maker, _hidden\n',
'pkg/a.py': 'def run():\n return 3\n',
'pkg/b.py': 'def other():\n return 4\n',
'pkg/nested/__init__.py': 'def other():\n return 11\n',
'namespace_pkg/a.py': 'def run():\n return 5\n',
'namespace_pkg/b.py': 'def other():\n return 6\n',
'star.py': 'from pkg import *\n',
'multi_star.py': 'from star import *\n',
'exports.py': '__all__ = ["exported"]\n',
'assigned_all.py': 'from pkg import exported\n__all__ = ["exported"]\n',
'imported_all.py': 'from pkg import exported\nfrom exports import __all__\n',
'local_all.py':
'from pkg import exported\ndef configure():\n from exports import __all__\n',
'assigned_star.py': 'from assigned_all import *\n',
'imported_star.py': 'from imported_all import *\n',
'blocked_multi.py': 'from assigned_star import *\n',
'local_star.py': 'from local_all import *\n',
'cycle_a.py': 'from cycle_b import *\ndef cyclic():\n return 7\n',
'cycle_b.py': 'from cycle_a import *\n',
'module_target.py': 'def helper():\n return 8\n',
'class_target.py': 'def helper():\n return 9\n',
'closure_target.py': 'def helper():\n return 10\n',
'decoy.py':
'def run():\n return 0\ndef exported():\n return 0\ndef _hidden():\n return 0\n',
'module_rebinding.py': `import module_target as mod
from values import run as mod
def module_rebound():
return mod.helper()
`,
'module_rebinding_reverse.py': `from values import run as mod
import module_target as mod
def module_rebound_reverse():
return mod.helper()
`,
'app.py': `import definitions_star
import values_star
import star
import multi_star
import assigned_star
import imported_star
import blocked_multi
import local_star
import cycle_b
import pkg.a
import namespace_pkg.a
import module_target as mod
def module_definition():
return definitions_star.run()
def module_value():
return values_star.run()
def imported_export():
return star.exported()
def transitive_export():
return multi_star.exported()
def imported_class():
return multi_star.Maker().work()
def private_export():
return multi_star._hidden()
def assigned_exports():
return assigned_star.exported()
def imported_exports():
return imported_star.exported()
def transitive_blocked_exports():
return blocked_multi.exported()
def local_exports():
return local_star.exported()
def cyclic_export():
return cycle_b.cyclic()
def sibling_package():
import pkg.b
return pkg.a.run()
def sibling_namespace_package():
import namespace_pkg.b
return namespace_pkg.a.run()
def named_shadow():
from origin import Maker as pkg
return pkg.a.run()
def unresolved_shadow():
import unavailable as pkg
return pkg.a.run()
def different_package():
import namespace_pkg.b as pkg
return pkg.a.run()
def root_named_module_alias():
import pkg.b as pkg
return pkg.other()
def root_named_package_alias():
import pkg.nested as pkg
return pkg.other()
def root_named_module_shadow():
import pkg.b as pkg
return pkg.a.run()
def root_named_package_shadow():
import pkg.nested as pkg
return pkg.a.run()
def root_named_module_path():
import pkg.b as pkg
return pkg.b.other()
def root_named_package_path():
import pkg.nested as pkg
return pkg.nested.other()
class ClassOnly:
import class_target as class_mod
value = class_mod.helper()
def class_only(self):
return class_mod.helper()
class WithModule:
import class_target as mod
value = mod.helper()
def module_visible(self):
return mod.helper()
def parameter_shadow(self, mod):
return mod.helper()
def namespace_rebound():
import module_target as mod
import class_target as mod
return mod.helper()
def named_rebound():
import module_target as mod
from values import run as mod
return mod.helper()
def named_rebound_reverse():
from values import run as mod
import module_target as mod
return mod.helper()
def conditional_rebound(flag):
if flag:
import module_target as mod
else:
from values import run as mod
return mod.helper()
def conditional_namespaces(flag):
if flag:
import module_target as mod
else:
import class_target as mod
return mod.helper()
def compatible_imports():
import pkg.a
import pkg.b
import pkg.a
pkg.a.run()
return pkg.b.other()
def duplicate_imports():
import module_target as mod
import module_target as mod
return mod.helper()
def enclosing():
import closure_target as enclosed_mod
class Nested:
import class_target as enclosed_mod
def closure_visible(self):
return enclosed_mod.helper()
return Nested
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
});
const callsFrom = (name: string) =>
getRelationships(result, 'CALLS').filter((edge) => edge.source === name);
it('keeps the exact module definition when a class method has the same name', () => {
expect(callsFrom('module_definition').map((edge) => edge.rel.targetId)).toEqual([
'Function:definitions.py:run',
]);
expect(callsFrom('module_value').map((edge) => edge.rel.targetId)).toEqual([
'Variable:values.py:run',
]);
});
it('publishes imported aliases through one or more wildcard barrels', () => {
for (const name of ['imported_export', 'transitive_export', 'local_exports']) {
expect(callsFrom(name).map((edge) => edge.rel.targetId)).toEqual([
'Function:origin.py:helper',
]);
}
expect(
callsFrom('imported_class').map(
(edge) => `${edge.targetLabel}:${edge.targetFilePath}:${edge.target}`,
),
).toEqual(['Class:origin.py:Maker', 'Method:origin.py:work']);
});
it('filters private names and declines explicit module-level __all__ through every hop', () => {
for (const name of [
'private_export',
'assigned_exports',
'imported_exports',
'transitive_blocked_exports',
]) {
expect(callsFrom(name), name).toEqual([]);
}
});
it('propagates wildcard exports through a bounded cycle', () => {
expect(callsFrom('cyclic_export').map((edge) => edge.rel.targetId)).toEqual([
'Function:cycle_a.py:cyclic',
]);
});
it('retains compatible outer submodule paths for regular and namespace packages', () => {
expect(callsFrom('sibling_package').map((edge) => edge.rel.targetId)).toEqual([
'Function:pkg/a.py:run',
]);
expect(callsFrom('sibling_namespace_package').map((edge) => edge.rel.targetId)).toEqual([
'Function:namespace_pkg/a.py:run',
]);
});
it('still suppresses outer paths under named, unresolved, and different namespace imports', () => {
for (const name of ['named_shadow', 'unresolved_shadow', 'different_package']) {
expect(callsFrom(name), name).toEqual([]);
}
});
it('binds root-spelled aliases to the imported module or package', () => {
expect(callsFrom('root_named_module_alias').map((edge) => edge.rel.targetId)).toEqual([
'Function:pkg/b.py:other',
]);
expect(callsFrom('root_named_package_alias').map((edge) => edge.rel.targetId)).toEqual([
'Function:pkg/nested/__init__.py:other',
]);
});
it('does not expose original or outer package paths through root-spelled aliases', () => {
for (const name of [
'root_named_module_shadow',
'root_named_package_shadow',
'root_named_module_path',
'root_named_package_path',
]) {
expect(callsFrom(name), name).toEqual([]);
}
});
it('suppresses conflicting imports in module and function scopes regardless of order', () => {
for (const name of [
'module_rebound',
'module_rebound_reverse',
'namespace_rebound',
'named_rebound',
'named_rebound_reverse',
'conditional_rebound',
'conditional_namespaces',
]) {
expect(callsFrom(name), name).toEqual([]);
}
});
it('preserves compatible dotted imports and repeated identical aliases in one scope', () => {
expect(
callsFrom('compatible_imports')
.map((edge) => edge.rel.targetId)
.sort(),
).toEqual(['Function:pkg/a.py:run', 'Function:pkg/b.py:other']);
expect(callsFrom('duplicate_imports').map((edge) => edge.rel.targetId)).toEqual([
'Function:module_target.py:helper',
]);
});
it('skips class imports when looking outward from methods', () => {
expect(callsFrom('class_only')).toEqual([]);
expect(callsFrom('module_visible').map((edge) => edge.rel.targetId)).toEqual([
'Function:module_target.py:helper',
]);
expect(callsFrom('closure_visible').map((edge) => edge.rel.targetId)).toEqual([
'Function:closure_target.py:helper',
]);
expect(callsFrom('parameter_shadow')).toEqual([]);
});
it('keeps class imports visible within their own class bodies', () => {
const classCalls = getRelationships(result, 'CALLS').filter(
(edge) => edge.rel.targetId === 'Function:class_target.py:helper',
);
expect(classCalls).toHaveLength(2);
expect(classCalls.map((edge) => edge.source).sort()).toEqual(['ClassOnly', 'WithModule']);
});
});

View file

@ -1013,6 +1013,281 @@ describe('Python re-export chain resolution', () => {
});
});
describe('Python aliased package re-export resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-aliased-package-reexport'),
() => {},
);
}, 60000);
it('resolves both aliased package member calls and direct re-export calls', () => {
const calls = getRelationships(result, 'CALLS')
.filter((call) => ['combo_caller', 'reexport_pkg_caller'].includes(call.source))
.map((call) => [call.source, call.target, call.targetFilePath])
.sort();
expect(calls).toEqual([
['combo_caller', 'pf', 'app/services/bp/gp.py'],
['reexport_pkg_caller', 'pf', 'app/services/bp/gp.py'],
]);
});
});
// ---------------------------------------------------------------------------
// Dotted imports: package prefixes must not borrow the leaf module's bindings
// ---------------------------------------------------------------------------
describe('Python dotted import package ownership', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-package-ownership-'));
writeFixtureRepo(repoDir, {
'app.py': `import pkg.db
import hidden_pkg.leaf
import deep.nested
def construct():
return pkg.User().save()
def hidden_on_package():
return hidden_pkg.hidden()
def visible_on_leaf():
return hidden_pkg.leaf.hidden()
def root_member():
return deep.marker()
def nested_member():
return deep.nested.marker()
def renamed_member():
return pkg.renamed()
`,
'pkg/__init__.py': 'from .db import User\nfrom .bridge import renamed\n__all__ = []\n',
'pkg/db.py': 'class User:\n def save(self):\n return 1\n',
'pkg/bridge.py': 'from .service import original as renamed\n',
'pkg/service.py': 'def original():\n return 2\n',
'hidden_pkg/__init__.py': '',
'hidden_pkg/leaf.py': 'from other import hidden\n',
'other.py': 'def hidden():\n return 3\n',
'deep/__init__.py': 'from .root_service import marker\n',
'deep/root_service.py': 'def marker():\n return 4\n',
'deep/nested/__init__.py': 'from .service import marker\n',
'deep/nested/service.py': 'def marker():\n return 5\n',
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('keeps the method call on a class re-exported from the imported leaf', () => {
const calls = getRelationships(result, 'CALLS').filter((call) => call.source === 'construct');
expect(calls.map((call) => [call.target, call.targetFilePath]).sort()).toEqual([
['User', 'pkg/db.py'],
['save', 'pkg/db.py'],
]);
});
it('does not publish leaf-only imports on the package', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.filter((call) => call.source === 'hidden_on_package')).toEqual([]);
expect(
calls
.filter((call) => call.source === 'visible_on_leaf')
.map((call) => [call.target, call.targetFilePath]),
).toEqual([['hidden', 'other.py']]);
});
it('keeps root and nested package re-exports in their own namespaces', () => {
const calls = getRelationships(result, 'CALLS')
.filter((call) => ['root_member', 'nested_member'].includes(call.source))
.map((call) => [call.source, call.target, call.targetFilePath])
.sort();
expect(calls).toEqual([
['nested_member', 'marker', 'deep/nested/service.py'],
['root_member', 'marker', 'deep/root_service.py'],
]);
});
it('follows renamed re-export chains for explicit access even outside __all__', () => {
const calls = getRelationships(result, 'CALLS')
.filter((call) => call.source === 'renamed_member')
.map((call) => [call.target, call.targetFilePath]);
expect(calls).toEqual([['original', 'pkg/service.py']]);
});
});
describe('Python namespace re-export visibility', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-reexport-visibility-'));
writeFixtureRepo(repoDir, {
'pkg/__init__.py': '',
'pkg/impl.py': `def run():
return 1
class Service:
def work(self):
return 2
def _hidden():
return 3
`,
'pkg/named.py': `from .impl import run as renamed, Service, _hidden
__all__ = []
`,
'pkg/chain.py': 'from .named import renamed as again\n',
'pkg/star.py': 'from .impl import *\n',
'pkg/restricted_impl.py': '__all__ = []\ndef run():\n return 5\n',
'pkg/restricted.py': 'from .restricted_impl import *\n',
'pkg/private.py': `def setup():
from .impl import run
return run()
class Holder:
from .impl import Service
`,
'pkg/local.py': `from .impl import run
def run():
return 4
`,
'inner.py': `from pkg import impl as api
def inner_alias():
from pkg import local as api
return api.run()
def sibling():
return api.run()
def local_only():
from pkg import impl as scoped
return scoped.run()
def absent_sibling():
return scoped.run()
def outer():
from pkg import local as scoped
def closure():
return scoped.run()
return closure()
`,
'consumer.py': `from pkg import named as ns, chain, star, private, local, restricted
def alias():
return ns.renamed()
def hidden():
return ns._hidden()
def chained():
return chain.again()
def wildcard():
return star.run()
def wildcard_private():
return star._hidden()
def wildcard_restricted():
return restricted.run()
def wildcard_nested():
return star.work()
def not_exported():
return private.run()
def not_exported_class():
return private.Service().work()
def receiver_shadow(ns):
return ns.renamed()
def local_member():
return local.run()
def compound():
return ns.Service().work()
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('resolves named, aliased and transitive members regardless of __all__', () => {
const edges = getRelationships(result, 'CALLS')
.filter((call) => ['alias', 'hidden', 'chained', 'compound'].includes(call.source))
.map((call) => `${call.source}->${call.target}@${call.targetFilePath}`)
.sort();
expect(edges).toEqual([
'alias->run@pkg/impl.py',
'chained->run@pkg/impl.py',
'compound->Service@pkg/impl.py',
'compound->work@pkg/impl.py',
'hidden->_hidden@pkg/impl.py',
]);
});
it('exposes wildcard-imported names as module members', () => {
const edges = getRelationships(result, 'CALLS')
.filter((call) => call.source === 'wildcard')
.map((call) => `${call.target}@${call.targetFilePath}`);
expect(edges).toEqual(['run@pkg/impl.py']);
expect(
getRelationships(result, 'CALLS').filter((call) =>
['wildcard_private', 'wildcard_restricted', 'wildcard_nested'].includes(call.source),
),
).toEqual([]);
});
it('does not publish function-local or class-body imports as module members', () => {
const edges = getRelationships(result, 'CALLS').filter((call) =>
['not_exported', 'not_exported_class'].includes(call.source),
);
expect(edges).toEqual([]);
expect(
getRelationships(result, 'CALLS')
.filter((call) => call.source === 'setup')
.map((call) => `${call.target}@${call.targetFilePath}`),
).toEqual(['run@pkg/impl.py']);
});
it('keeps namespace imports local to their function and visible to closures', () => {
const edges = getRelationships(result, 'CALLS')
.filter((call) => call.sourceFilePath === 'inner.py' && call.target === 'run')
.map((call) => `${call.source}->${call.targetFilePath}`)
.sort();
expect(edges).toEqual([
'closure->pkg/local.py',
'inner_alias->pkg/local.py',
'local_only->pkg/impl.py',
'sibling->pkg/impl.py',
]);
});
it('preserves local-member and receiver shadowing', () => {
const edges = getRelationships(result, 'CALLS')
.filter((call) => ['local_member', 'receiver_shadow'].includes(call.source))
.map((call) => `${call.source}->${call.target}@${call.targetFilePath}`);
expect(edges).toEqual(['local_member->run@pkg/local.py']);
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------
@ -3849,12 +4124,8 @@ describe('Python class-body attribute does NOT leak into module export index', (
// ---------------------------------------------------------------------------
// Function-local import + cross-file return-type propagation
// Codex round-2 flagged this as potentially broken, but empirically the
// finalize-algorithm hoists the `from svc import get_user` binding to
// the app.py module scope (observed via indexes.bindings dump), so
// `propagateImportedReturnTypes`'s module-scope pass already handles
// it. These assertions pin that working behavior as a regression
// guard against any future change to binding-scope routing.
// The import stays in its function scope; return-type propagation must still
// connect a call on the imported function's result to its defining method.
// ---------------------------------------------------------------------------
describe('Python function-local import feeds chained receiver-bound call', () => {
@ -3885,13 +4156,8 @@ describe('Python function-local import feeds chained receiver-bound call', () =>
// ---------------------------------------------------------------------------
// Function-local namespace import: `def f(): import svc as s; s.call()`
// Codex round-3 flagged this pattern as potentially broken because
// collectNamespaceTargets reads only module-scope imports. Empirically
// the edge IS emitted (finalize hoists ImportEdges onto the module
// scope), so these assertions pin the working behavior. If finalize
// routing ever changes to match pythonImportOwningScope's per-scope
// contract, this block will flip red and signal the need to make
// collectNamespaceTargets scope-chain-aware.
// Namespace targets follow the lexical scope chain, so the local import is
// visible inside its function without becoming a module export.
// ---------------------------------------------------------------------------
describe('Python function-local namespace import feeds receiver-bound call', () => {
@ -3921,13 +4187,10 @@ describe('Python function-local namespace import feeds receiver-bound call', ()
// ---------------------------------------------------------------------------
// Class-body namespace import: `class A: import mod; def use(): mod.helper()`
// Same theoretical concern as the function-local case above, same
// empirical outcome — finalize hoists the ImportEdge to the module
// scope so the namespace-receiver path finds it from inside A.use.
// These assertions pin that working behavior.
// Class namespaces are not enclosing lexical environments for method bodies.
// ---------------------------------------------------------------------------
describe('Python class-body namespace import feeds method receiver-bound call', () => {
describe('Python class-body namespace import is invisible to methods', () => {
let result: PipelineResult;
beforeAll(async () => {
@ -3937,11 +4200,10 @@ describe('Python class-body namespace import feeds method receiver-bound call',
);
}, 60000);
it('emits CALLS edge A.use -> mod.helper via class-body `import mod`', () => {
it('does not emit A.use -> mod.helper for a class-body-only import', () => {
const calls = getRelationships(result, 'CALLS');
const callEdge = calls.find((c) => c.source === 'use' && c.target === 'helper');
expect(callEdge).toBeDefined();
expect(callEdge!.rel.targetId).toContain('mod.py:helper');
expect(callEdge).toBeUndefined();
});
});

View file

@ -303,8 +303,8 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 125 -> 126 for #3446: SDK positional tool definitions and attribution.
// Moved 126 -> 127 for #3450: reject destructured SDK registration-method writes.
// Moved 127 -> 128 for #3450: recognize SDK namespace imports.
it('pins SCHEMA_BUMP to 130 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(130);
it('pins SCHEMA_BUMP to 131 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3504)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(131);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
@ -314,7 +314,7 @@ describe('PARSE_CACHE_VERSION', () => {
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
123, 124, 125, 126, 127,
123, 124, 125, 126, 127, 128, 129, 130,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared';
import { collectNamespaceTargets } from '../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js';
import { pythonNamespaceReceiverPaths } from '../../../src/core/ingestion/languages/python/import-target.js';
import {
pythonNamespaceReceiverPaths,
pythonNamespaceBindingIdentity,
} from '../../../src/core/ingestion/languages/python/import-target.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
// `collectNamespaceTargets` reads exactly two things: the file's module scope
// and `scopes.imports`. Hand-building those keeps this test about the keying
// The module-only cases need just a module scope and `scopes.imports`; the
// lexical cases also supply parent scopes. This keeps the test about the keying
// rule itself rather than about any one language's parser — which matters,
// because the rule's whole job is to tell otherwise identical-looking edges
// from different languages apart.
@ -29,6 +32,7 @@ function collectPython(edges: readonly ImportEdge[], files: readonly string[] =
const present = new Set(files);
return collectNamespaceTargets(parsed, scopes, {
receiverPaths: pythonNamespaceReceiverPaths,
bindingIdentity: pythonNamespaceBindingIdentity,
moduleFileExists: (filePath) => present.has(filePath),
});
}
@ -57,7 +61,7 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
// happened to export `helper`, silently preferring a decoy over the real one.
it('keys the package root at its own __init__, never at the leaf module', () => {
const targets = collectPython([edge({})], ['pkg/__init__.py']);
expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py']);
expect(targets.get('pkg')).toEqual(['pkg/__init__.py']);
expect(targets.get('pkg.db')).toEqual(['pkg/db.py']);
});
@ -65,7 +69,7 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
// PEP-420 namespace package: no __init__.py. Better no key than one
// pointing at a file that does not exist — or at the wrong file.
const targets = collectPython([edge({})], []);
expect(targets.get('pkg')).toEqual(['pkg/db.py']);
expect(targets.has('pkg')).toBe(false);
expect(targets.get('pkg.db')).toEqual(['pkg/db.py']);
});
@ -76,8 +80,8 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
targetFile: 'a/b/c.py',
});
const targets = collectPython([deep], ['a/__init__.py', 'a/b/__init__.py']);
expect(targets.get('a')).toEqual(['a/__init__.py', 'a/b/c.py']);
expect(targets.get('a.b')).toEqual(['a/b/__init__.py', 'a/b/c.py']);
expect(targets.get('a')).toEqual(['a/__init__.py']);
expect(targets.get('a.b')).toEqual(['a/b/__init__.py']);
expect(targets.get('a.b.c')).toEqual(['a/b/c.py']);
});
@ -102,13 +106,30 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
it('does not key an alias import under the module path it does not bind', () => {
// `import pkg.db as pdb` binds ONLY `pdb`; `pkg.db.f()` is a NameError.
const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db' });
const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db', explicitAlias: true });
const targets = collectPython([aliased], ['pkg/__init__.py']);
expect(targets.get('pdb')).toEqual(['pkg/db.py']);
expect(targets.has('pkg.db')).toBe(false);
expect(targets.has('pkg')).toBe(false);
});
it.each(['pkg/db.py', 'pkg/db/__init__.py'])(
'keeps a root-spelled alias bound only to %s',
(targetFile) => {
const aliased = edge({ targetFile, explicitAlias: true });
const targets = collectPython([aliased], ['pkg/__init__.py', targetFile]);
expect([...targets]).toEqual([['pkg', [targetFile]]]);
expect(
pythonNamespaceBindingIdentity({
localName: 'pkg',
importPath: 'pkg.db',
targetFile,
explicitAlias: true,
}),
).toBe(targetFile);
},
);
it('keeps two same-package imports on separate keys', () => {
const targets = collectPython(
[
@ -119,10 +140,9 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
);
expect(targets.get('pkg.db')).toEqual(['pkg/db.py']);
expect(targets.get('pkg.cache')).toEqual(['pkg/cache.py']);
// The shared root LEADS with the package itself — not with whichever
// submodule happened to be imported first — and keeps both leaves behind it
// so a name merely re-exported by `__init__.py` still resolves.
expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py', 'pkg/cache.py']);
// Both imports bind the same root package, without publishing either
// submodule's members on it.
expect(targets.get('pkg')).toEqual(['pkg/__init__.py']);
});
it('ignores non-namespace and unresolved edges', () => {
@ -151,10 +171,7 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
[offRoot],
['utils/__init__.py', 'libs/common/utils/__init__.py'],
);
expect(targets.get('utils')).toEqual([
'libs/common/utils/__init__.py',
'libs/common/utils/db.py',
]);
expect(targets.get('utils')).toEqual(['libs/common/utils/__init__.py']);
expect(targets.get('utils.db')).toEqual(['libs/common/utils/db.py']);
});
@ -165,8 +182,8 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
targetFile: 'src/a/b/c.py',
});
const targets = collectPython([srcLayout], ['src/a/__init__.py', 'src/a/b/__init__.py']);
expect(targets.get('a')).toEqual(['src/a/__init__.py', 'src/a/b/c.py']);
expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py', 'src/a/b/c.py']);
expect(targets.get('a')).toEqual(['src/a/__init__.py']);
expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py']);
expect(targets.get('a.b.c')).toEqual(['src/a/b/c.py']);
});
@ -178,4 +195,145 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
});
expect(collectPython([bare]).get('single')).toEqual(['single.py']);
});
it('keeps each prefix on its own package when the leaf is an __init__.py', () => {
const packageLeaf = edge({
localName: 'a',
targetExportedName: 'a.b.c',
targetFile: 'src/a/b/c/__init__.py',
});
const targets = collectPython(
[packageLeaf],
['src/a/__init__.py', 'src/a/b/__init__.py', 'src/a/b/c/__init__.py'],
);
expect(targets.get('a')).toEqual(['src/a/__init__.py']);
expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py']);
expect(targets.get('a.b.c')).toEqual(['src/a/b/c/__init__.py']);
});
});
describe('collectNamespaceTargets — lexical import scopes', () => {
const inner = 'scope:inner' as ScopeId;
const sibling = 'scope:sibling' as ScopeId;
const closure = 'scope:closure' as ScopeId;
const parsed = { moduleScope: MODULE_SCOPE };
const parents = new Map([
[inner, MODULE_SCOPE],
[sibling, MODULE_SCOPE],
[closure, inner],
]);
function collect(inScope: ScopeId, innerEdge: ImportEdge = edge({ targetFile: 'local.py' })) {
const scopes = {
imports: new Map([
[MODULE_SCOPE, [edge({})]],
[inner, [innerEdge]],
]),
scopeTree: { getScope: (id: ScopeId) => ({ parent: parents.get(id) ?? null }) },
} as unknown as ScopeResolutionIndexes;
return collectNamespaceTargets(parsed, scopes, { inScope });
}
it('uses the nearest import without leaking it to a sibling or the module', () => {
expect(collect(inner).get('pkg')).toEqual(['local.py']);
expect(collect(closure).get('pkg')).toEqual(['local.py']);
expect(collect(sibling).get('pkg')).toEqual(['pkg/db.py']);
expect(collect(MODULE_SCOPE).get('pkg')).toEqual(['pkg/db.py']);
});
it('does not revive an outer namespace hidden by a named import', () => {
expect(collect(inner, edge({ kind: 'named', targetFile: 'local.py' })).has('pkg')).toBe(false);
});
it('does not revive an outer namespace when the nearest import is unresolved', () => {
expect(collect(inner, edge({ targetFile: null, linkStatus: 'unresolved' })).has('pkg')).toBe(
false,
);
});
function compatibleTargets(outer: ImportEdge, nearest: ImportEdge) {
const scopes = {
imports: new Map([
[MODULE_SCOPE, [outer]],
[inner, [nearest]],
]),
scopeTree: { getScope: (id: ScopeId) => ({ parent: parents.get(id) ?? null }) },
} as unknown as ScopeResolutionIndexes;
return collectNamespaceTargets(parsed, scopes, {
inScope: inner,
receiverPaths: pythonNamespaceReceiverPaths,
bindingIdentity: pythonNamespaceBindingIdentity,
});
}
it('retains sibling paths only when their resolved root package agrees', () => {
const nearest = edge({ targetExportedName: 'pkg.cache', targetFile: 'lib/pkg/cache.py' });
expect(compatibleTargets(edge({ targetFile: 'lib/pkg/db.py' }), nearest).get('pkg.db')).toEqual(
['lib/pkg/db.py'],
);
expect(compatibleTargets(edge({ targetFile: 'other/pkg/db.py' }), nearest).has('pkg.db')).toBe(
false,
);
});
it('recognizes the same root object when the nearest import names the package directly', () => {
const nearest = edge({ targetExportedName: 'pkg', targetFile: 'lib/pkg/__init__.py' });
expect(compatibleTargets(edge({ targetFile: 'lib/pkg/db.py' }), nearest).get('pkg.db')).toEqual(
['lib/pkg/db.py'],
);
});
it('does not retain outer root paths under an explicit alias to a submodule', () => {
const nearest = edge({
targetExportedName: 'pkg.cache',
targetFile: 'pkg/cache.py',
explicitAlias: true,
});
expect([...compatibleTargets(edge({}), nearest)]).toEqual([['pkg', ['pkg/cache.py']]]);
});
});
describe('collectNamespaceTargets — same-scope import ownership', () => {
const first = edge({ localName: 'mod', targetFile: 'alpha.py', explicitAlias: true });
const named = edge({ localName: 'mod', kind: 'named', targetFile: 'values.py' });
const other = edge({ localName: 'mod', targetFile: 'beta.py', explicitAlias: true });
const unresolved = edge({ localName: 'mod', targetFile: null, linkStatus: 'unresolved' });
it.each([
['different namespaces', [first, other]],
['namespace then named', [first, named]],
['named then namespace', [named, first]],
['namespace then unresolved', [first, unresolved]],
['unresolved then namespace', [unresolved, first]],
['repeated original after conflict', [first, other, first]],
['named import between duplicates', [first, named, first]],
] as const)('suppresses %s', (_name, imports) => {
expect([...collectPython(imports)]).toEqual([]);
});
it('deduplicates repeated imports of the same namespace', () => {
expect([...collectPython([first, first])]).toEqual([['mod', ['alpha.py']]]);
});
it('preserves multiple targets without a binding identity hook', () => {
expect([...collectDefault([first, named, other])]).toEqual([['mod', ['alpha.py', 'beta.py']]]);
});
it('keeps a lone namespace whose binding identity cannot be established', () => {
expect(collectPython([edge({ targetFile: 'db.py' })]).get('pkg.db')).toEqual(['db.py']);
});
it('does not revive an outer namespace after suppressing an inner conflict', () => {
const inner = 'scope:conflicting' as ScopeId;
const scopes = {
imports: new Map([
[MODULE_SCOPE, [first]],
[inner, [first, named]],
]),
scopeTree: { getScope: () => ({ parent: MODULE_SCOPE }) },
} as unknown as ScopeResolutionIndexes;
expect([
...collectNamespaceTargets({ moduleScope: MODULE_SCOPE }, scopes, {
inScope: inner,
receiverPaths: pythonNamespaceReceiverPaths,
bindingIdentity: pythonNamespaceBindingIdentity,
}),
]).toEqual([]);
});
});

View file

@ -203,6 +203,7 @@ describe('Python imports — interpretImport', () => {
{
kind: 'namespace',
localName: 'np',
explicitAlias: true,
importedName: 'numpy',
targetRaw: 'numpy',
declaredAtScope: f.moduleScope,
@ -223,6 +224,20 @@ describe('Python imports — interpretImport', () => {
]);
});
it('keeps an explicit alias when it matches the imported path root', () => {
const f = parse('import a.b as a\n');
expect(f.parsedImports).toEqual([
{
kind: 'namespace',
localName: 'a',
explicitAlias: true,
importedName: 'a.b',
targetRaw: 'a.b',
declaredAtScope: f.moduleScope,
},
]);
});
it('case 13: `import a, b as c` decomposes into one ParsedImport per name', () => {
const f = parse('import a, b as c\n');
expect(f.parsedImports).toEqual([
@ -236,6 +251,7 @@ describe('Python imports — interpretImport', () => {
{
kind: 'namespace',
localName: 'c',
explicitAlias: true,
importedName: 'b',
targetRaw: 'b',
declaredAtScope: f.moduleScope,

View file

@ -34,6 +34,12 @@ def locally_shadowed():
def dotted():
return pkg.models.User()
def reexported():
return pkg.User()
def missing_package_member():
return pkg.Hidden()
def dotted_root_shadowed():
pkg = object()
return pkg.models.User()
@ -41,15 +47,33 @@ def dotted_root_shadowed():
],
[
'pkg/models.py',
`class User:
`from decoy import Hidden
class User:
pass
`,
],
['pkg/__init__.py', '# package marker\n'],
['pkg/__init__.py', 'from .models import User\n'],
[
'pkg/package_leaf.py',
`import pkg.subpkg
def from_root():
return pkg.User()
def from_leaf():
return pkg.subpkg.User()
`,
],
['pkg/subpkg/__init__.py', 'from .models import User\n'],
['pkg/subpkg/models.py', 'class User:\n pass\n'],
[
'decoy.py',
`class Missing:
pass
class Hidden:
pass
`,
],
]);
@ -99,14 +123,18 @@ function build() {
return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, {
constructionSyntax: { bare: true },
namespaceTargets,
namespaceExportsIncludeImportedNames:
pythonScopeResolver.namespaceExportsIncludeImportedNames,
});
};
const dotted = parsedFiles.find((file) => file.filePath === 'pkg/dotted.py');
if (dotted === undefined) throw new Error('missing dotted fixture');
const dottedNamespaceTargets = namespaceTargetsFor(dotted);
const resolveDottedIn = (functionName: string, expression: string) => {
const resolveDottedIn = (
functionName: string,
expression: string,
filePath = 'pkg/dotted.py',
) => {
const dotted = parsedFiles.find((file) => file.filePath === filePath);
if (dotted === undefined) throw new Error(`missing fixture ${filePath}`);
const functionScope = dotted.scopes.find(
(scope) =>
scope.kind === 'Function' &&
@ -115,7 +143,9 @@ function build() {
if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`);
return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, {
constructionSyntax: { bare: true },
namespaceTargets: dottedNamespaceTargets,
namespaceTargets: namespaceTargetsFor(dotted),
namespaceExportsIncludeImportedNames:
pythonScopeResolver.namespaceExportsIncludeImportedNames,
});
};
@ -153,6 +183,28 @@ describe('Python module namespace construction', () => {
});
});
it('does not treat a class re-exported from the imported leaf as ambiguous', () => {
expect(resolveDottedIn('reexported', 'pkg.User()')).toMatchObject({
filePath: 'pkg/models.py',
qualifiedName: 'User',
});
});
it('does not expose an imported leaf member on the package', () => {
expect(resolveDottedIn('missing_package_member', 'pkg.Hidden()')).toBeUndefined();
});
it('resolves root and leaf re-exports from their own package namespaces', () => {
expect(resolveDottedIn('from_root', 'pkg.User()', 'pkg/package_leaf.py')).toMatchObject({
filePath: 'pkg/models.py',
qualifiedName: 'User',
});
expect(resolveDottedIn('from_leaf', 'pkg.subpkg.User()', 'pkg/package_leaf.py')).toMatchObject({
filePath: 'pkg/subpkg/models.py',
qualifiedName: 'User',
});
});
it('does not reuse a dotted namespace when a local shadows its ROOT segment', () => {
// Fails without the root-segment fix: testing the whole `pkg.models`
// string against scope bindings never matches, so the guard would pass a

View file

@ -16,19 +16,24 @@
* about which module owns `scale`.
*
* WHY THE INDEXES ARE HAND-BUILT, stated so this is not read as a fixture that
* "just happens" to be synthetic. Zig forbids declaring a name twice in one
* container, and Zig is today the only provider that sets
* `namespaceExportsIncludeImportedNames`, so no valid Zig source can put a
* local non-callable and a published callable under one name in one module —
* there is no source-level fixture to write. The shape becomes reachable the
* moment a second provider opts in, or a receiver name binds more than one
* target file. Building the indexes directly is what lets the guard be pinned
* before that happens; the middle case below fails without it.
* "just happens" to be synthetic. Python and Zig both set
* `namespaceExportsIncludeImportedNames`; Zig forbids declaring a name twice
* in one container, so its source cannot express the overlapping bindings
* tested here. Building the indexes directly isolates the shared precedence
* contract from provider extraction and finalization. The middle case below
* fails without the local-name guard.
*/
import { describe, it, expect } from 'vitest';
import { resolveValueRefTarget } from '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js';
import type { BindingRef, ReferenceSite, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type {
BindingRef,
ImportEdge,
ReferenceSite,
Scope,
ScopeId,
SymbolDefinition,
} from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
@ -125,6 +130,12 @@ describe('findNamespaceValueRefTarget — local declarations outrank re-publishe
);
});
it('resolves a callable published through a wildcard import', () => {
expect(resolve(indexes({ published: [ref(PUBLISHED, 'wildcard')] }))?.nodeId).toBe(
PUBLISHED.nodeId,
);
});
it('declines when the hub declares a NON-callable under the name', () => {
// The regression. The local lookup type-gates before precedence is settled,
// so `scale` answered nothing locally and the published channel bound
@ -168,3 +179,76 @@ describe('findNamespaceValueRefTarget — local declarations outrank re-publishe
).toBeUndefined();
});
});
describe('namespace value references follow lexical visibility', () => {
const INNER = 'scope:inner';
const SIBLING = 'scope:sibling';
const CLASS = 'scope:class';
const METHOD = 'scope:method';
const OTHER = 'scope:other:module';
const OTHER_FILE = 'src/other.zig';
const OTHER_FN = def('Function:src/other.zig:scale', 'Function', OTHER_FILE);
function fixture(edge: Partial<ImportEdge> = {}, importScope = INNER) {
const base = indexes({ local: [ref(LOCAL_FN, 'local')] });
const extraScopes = new Map<ScopeId, Scope>([
[INNER, { ...moduleScope(INNER, CONSUMER_FILE), kind: 'Function', parent: CONSUMER }],
[SIBLING, { ...moduleScope(SIBLING, CONSUMER_FILE), kind: 'Function', parent: CONSUMER }],
[CLASS, { ...moduleScope(CLASS, CONSUMER_FILE), kind: 'Class', parent: INNER }],
[METHOD, { ...moduleScope(METHOD, CONSUMER_FILE), kind: 'Function', parent: CLASS }],
[OTHER, moduleScope(OTHER, OTHER_FILE)],
]);
return {
...base,
scopeTree: { getScope: (id: ScopeId) => extraScopes.get(id) ?? base.scopeTree.getScope(id) },
moduleScopes: {
get: (file: string) => (file === OTHER_FILE ? OTHER : base.moduleScopes.get(file)),
},
imports: new Map([
...base.imports,
[
importScope,
[
{
localName: 'hub',
targetExportedName: 'other',
targetFile: OTHER_FILE,
kind: 'namespace',
...edge,
} as ImportEdge,
],
],
]),
bindings: new Map([
...base.bindings,
[OTHER, new Map([['scale', [ref(OTHER_FN, 'local')]]])],
]),
} as unknown as ScopeResolutionIndexes;
}
function at(scope: ScopeId, scopes: ScopeResolutionIndexes) {
return resolveValueRefTarget({ ...SITE, inScope: scope }, CONSUMER_FILE, scopes, MODEL, true, {
skipEnclosingClasses: true,
});
}
it('selects the nearest import for a registration without leaking it to a sibling', () => {
const scopes = fixture();
expect(at(INNER, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
expect(at(METHOD, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
expect(at(SIBLING, scopes)?.nodeId).toBe(LOCAL_FN.nodeId);
});
it.each([{ kind: 'named' as const }, { targetFile: null, linkStatus: 'unresolved' as const }])(
'does not revive the outer namespace under a shadowing import: %j',
(edge) => {
expect(at(INNER, fixture(edge))).toBeUndefined();
},
);
it('skips an enclosing class import but retains it for class-body registrations', () => {
const scopes = fixture({}, CLASS);
expect(at(CLASS, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
expect(at(METHOD, scopes)?.nodeId).toBe(LOCAL_FN.nodeId);
});
});