From ff922c0a3b0cfc997dfd5b0d35f0c60955f1dc6c Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 8 Oct 2026 11:51:20 +0100 Subject: [PATCH] fix(python): resolve calls through aliased package re-exports (#3504) --- .../scope-resolution/finalize-algorithm.ts | 111 ++++++- gitnexus-shared/src/scope-resolution/types.ts | 4 + .../python-scope/baseline-fingerprint.txt | 2 +- .../bench/value-ref-resolution/measure.mjs | 12 +- .../core/ingestion/finalize-orchestrator.ts | 13 +- .../languages/python/expand-wildcards.ts | 62 ++++ .../languages/python/import-target.ts | 52 ++- .../core/ingestion/languages/python/index.ts | 1 + .../ingestion/languages/python/interpret.ts | 1 + .../languages/python/scope-resolver.ts | 9 + .../contract/scope-resolver.ts | 28 ++ .../passes/compound-receiver.ts | 11 +- .../passes/property-dispatch.ts | 47 ++- .../passes/receiver-bound-calls.ts | 69 +++- .../scope-resolution/pipeline/run.ts | 13 + .../scope/namespace-targets.ts | 100 ++++-- .../scope-resolution/scope/walkers.ts | 12 +- gitnexus/src/storage/parse-cache.ts | 5 +- .../app/__init__.py | 1 + .../app/combo.py | 4 + .../app/reex.py | 4 + .../app/services/__init__.py | 1 + .../app/services/bp/__init__.py | 5 + .../app/services/bp/gp.py | 2 + .../expected-captures.json | 24 ++ .../python-namespace-semantics.test.ts | 314 ++++++++++++++++++ .../test/integration/resolvers/python.test.ts | 304 +++++++++++++++-- .../test/unit/incremental-parse-cache.test.ts | 6 +- .../namespace-targets-import-path.test.ts | 194 ++++++++++- .../python/python-fixtures.test.ts | 16 + ...thon-module-namespace-construction.test.ts | 68 +++- .../value-ref-namespace-precedence.test.ts | 102 +++++- 32 files changed, 1427 insertions(+), 170 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/python/expand-wildcards.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/combo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/reex.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/gp.py create mode 100644 gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index dad6ae6ea..2254a835a 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -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; /** * 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, ambiguous: ReadonlyMap>, topLevelOnly: boolean, + filterWildcardNames: FinalizeHooks['filterWildcardNames'], + workspace: WorkspaceIndex, ): ReadonlyMap { const closures = new Map>(); 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>, ambiguousByFile: ReadonlyMap>, 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 { + return ( + file.localExports ?? + (topLevelOnly ? indexTopLevelExportsByName(file.localDefs) : indexExportsByName(file.localDefs)) + ); } /** @@ -1531,6 +1599,7 @@ function expandWildcard( byFilePath: Map, hooks: FinalizeHooks, workspace: WorkspaceIndex, + reexportClosures: ReadonlyMap, ): 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 diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 7f9a1b919..c62ae2625 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -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. */ diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index b9a9286eb..9bd636da6 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -1ab17d9cb80c3863ac9df17e4eefe4983a21f8d9908c110e8a8d9fa1b35efcc0 +b6754e5362716211a348f3aafda8bbc00082e3fb0e45cb3aa79a1be5e3df1a8b diff --git a/gitnexus/bench/value-ref-resolution/measure.mjs b/gitnexus/bench/value-ref-resolution/measure.mjs index a1c14f448..00c985875 100644 --- a/gitnexus/bench/value-ref-resolution/measure.mjs +++ b/gitnexus/bench/value-ref-resolution/measure.mjs @@ -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 diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 13320221b..0c1701778 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -68,6 +68,8 @@ export interface FinalizeOrchestratorOptions { * merge for bindings. */ readonly hooks?: Partial; + /** Optional exact module export surface, independent of nested definitions. */ + readonly moduleExports?: (file: ParsedFile) => ReadonlyMap; /** * 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, +): 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 { wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true, namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true, expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), + filterWildcardNames: partial.filterWildcardNames, mergeBindings: partial.mergeBindings ?? (( diff --git a/gitnexus/src/core/ingestion/languages/python/expand-wildcards.ts b/gitnexus/src/core/ingestion/languages/python/expand-wildcards.ts new file mode 100644 index 000000000..b0dcb1b2a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/expand-wildcards.ts @@ -0,0 +1,62 @@ +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; + +const namesByWorkspace = new WeakMap< + readonly ParsedFile[], + ReadonlyMap +>(); + +/** 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 { + const exports = new Map(); + 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(); + 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('_')), + ), + ]; +} diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index e31f038b0..f48359c1b 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -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'; +} diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index 18f9da132..f37e162ef 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -77,6 +77,7 @@ export { pythonArityCompatibility } from './arity.js'; export { isPythonImportedModule, pythonNamespaceReceiverPaths, + pythonNamespaceBindingIdentity, resolvePythonImportTarget, type PythonResolveContext, } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index 0f00babcd..5ceac3f12 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -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, }; diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index da30b470c..628af9a0e 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 47b34f315..4c6f6b595 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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; + + /** 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index c39c02a9c..c25d2d0f7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts index 6a23ca94d..0d5ac70fb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 4c556e967..c526f3bd7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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>(); // 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 8a28561ed..6297dcba3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index e80b7f359..d240c82d6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -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, scopes: ScopeResolutionIndexes, options?: NamespaceTargetOptions, ): Map { const out = new Map(); - 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(); + 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(); + for (const id of scopeIds) { + const edges = scopes.imports.get(id) ?? []; + const namesHere = new Map(); + const conflictingNames = new Set(); + 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; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 54daf1c92..12a5f92cb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -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; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 44a8cd294..b91dc6089 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/__init__.py @@ -0,0 +1 @@ + diff --git a/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/combo.py b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/combo.py new file mode 100644 index 000000000..b217d26d3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/combo.py @@ -0,0 +1,4 @@ +from .services import bp as backplane + +def combo_caller(): + return backplane.pf() diff --git a/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/reex.py b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/reex.py new file mode 100644 index 000000000..e86b6ea0f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/reex.py @@ -0,0 +1,4 @@ +from .services.bp import pf + +def reexport_pkg_caller(): + return pf() diff --git a/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/__init__.py new file mode 100644 index 000000000..3ed5473d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/__init__.py @@ -0,0 +1,5 @@ +from .gp import ( + pf, +) + +__all__ = ["pf"] diff --git a/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/gp.py b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/gp.py new file mode 100644 index 000000000..8c6c68a71 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/gp.py @@ -0,0 +1,2 @@ +def pf(): + return 1 diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 7d82afb1b..af0e6985a 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -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" diff --git a/gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts b/gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts new file mode 100644 index 000000000..7879012a5 --- /dev/null +++ b/gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts @@ -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']); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 35e310fda..402eae391 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -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(); }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 6e27bbfac..ea4489a4b 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts index 20ca10078..cc851f9f6 100644 --- a/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts +++ b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts @@ -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([]); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts b/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts index 252556cf2..962cf074e 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts @@ -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, diff --git a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts index 5b7d3402b..767e9d941 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts @@ -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 diff --git a/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts b/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts index 67c3019a9..9d351e1f5 100644 --- a/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts +++ b/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts @@ -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 = {}, importScope = INNER) { + const base = indexes({ local: [ref(LOCAL_FN, 'local')] }); + const extraScopes = new Map([ + [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); + }); +});