diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5913b17b08..86ad6c4263 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -247,6 +247,7 @@ Single interface a language implements to plug into the pipeline. Contract fully | `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) | | `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map` — C3, Ruby-mixin, or first-wins per language | | `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) | +| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module | | `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence | | `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | | `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f381bb12e3..e50337af3d 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -96,6 +96,16 @@ export interface FinalizeHooks { parsedImport?: ParsedImport, ): string | readonly string[] | null; + /** + * Reclassify syntax that names an imported symbol as a namespace import + * after target resolution proves the symbol is itself a module. + */ + readonly isNamespaceImport?: ( + parsedImport: ParsedImport, + targetFile: string, + fromFile: string, + ) => boolean; + /** * For a wildcard `import * from M`, return the names visible in the * exporting module scope `M`. The finalize pass looks each name up in @@ -389,7 +399,10 @@ function makeEdgeDrafts( localName: extractLocalName(parsed), targetFile: tf, targetExportedName: extractExportedName(parsed), - kind: edgeKindFor(parsed), + kind: + hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true + ? 'namespace' + : edgeKindFor(parsed), }; return { source: parsed, @@ -461,7 +474,7 @@ function tryFinalize( // languages emit a synthetic module-def), pick it up as the `targetDefId` // so consumers can reach the module as a symbol — but its absence is not // a failure. - if (draft.source.kind === 'namespace') { + if (draft.base.kind === 'namespace') { const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source)); return { ...draft.base, diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 180cb74a68..dbefe60d76 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -415,6 +415,11 @@ export interface Scope { /** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */ readonly typeBindings: ReadonlyMap; + /** Lexically bound names that may have no definition or type fact of their + * own (for example, an untyped function parameter). Consumers use this only + * as a shadowing barrier; it never resolves a symbol by itself. */ + readonly lexicalNames?: ReadonlySet; + /** Receiver names this scope BINDS rather than inherits — `this`, `self`, … (#2701). * * A receiver walk (`findReceiverTypeBinding`) that reaches such a scope diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 2c682ce694..b27ff7a41b 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483 +b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2 diff --git a/gitnexus/bench/python-scope/measure.mjs b/gitnexus/bench/python-scope/measure.mjs index 1ecd43b16e..7254c35432 100644 --- a/gitnexus/bench/python-scope/measure.mjs +++ b/gitnexus/bench/python-scope/measure.mjs @@ -42,6 +42,9 @@ const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'la function canonicalizeMatch(match) { const parts = []; for (const tag of Object.keys(match)) { + // Scope-only lexical shadow metadata is correctness-tested separately and + // does not alter capture matching or the benchmark's scaling contract. + if (tag === '@scope.lexical-names') continue; const cap = match[tag]; const r = cap.range; parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`); diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 02717a0b9c..3146aff26a 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -201,6 +201,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { function withDefaultHooks(partial: Partial): FinalizeHooks { return { resolveImportTarget: partial.resolveImportTarget ?? (() => null), + isNamespaceImport: partial.isNamespaceImport, expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), mergeBindings: partial.mergeBindings ?? diff --git a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts index 87e53f4f4a..d4da7dfd7e 100644 --- a/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/python/arity-metadata.ts @@ -22,6 +22,7 @@ interface PythonArityMetadata { readonly parameterCount: number | undefined; readonly requiredParameterCount: number | undefined; readonly parameterTypes: readonly string[] | undefined; + readonly parameterNames: readonly string[]; } export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetadata { @@ -50,5 +51,6 @@ export function computePythonArityMetadata(fnNode: SyntaxNode): PythonArityMetad parameterCount, requiredParameterCount, parameterTypes: types.length > 0 ? types : undefined, + parameterNames: params.map((parameter) => parameter.name), }; } diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 0b0e981156..a6d19fdc45 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -146,6 +146,14 @@ export function emitPythonScopeCaptures( const scopeNode = nodeMap['@scope.function']!; const fnNode = scopeNode.type === 'function_definition' ? scopeNode : null; if (fnNode !== null) { + const parameterNames = computePythonArityMetadata(fnNode).parameterNames; + if (parameterNames.length > 0) { + grouped['@scope.lexical-names'] = syntheticCapture( + '@scope.lexical-names', + fnNode, + JSON.stringify(parameterNames), + ); + } const synth = synthesizeReceiverTypeBinding(fnNode); if (synth !== null) out.push(synth); out.push(...synthesizeConstructorFieldTypeBindings(fnNode)); diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index 0899353fcd..d30823b92b 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -10,7 +10,7 @@ * `linkStatus: 'unresolved'`. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { resolvePythonImportInternal } from '../../import-resolvers/python.js'; import { recordPythonFileIndexBuild } from './index-stats.js'; @@ -20,6 +20,9 @@ export interface PythonResolveContext { * through to `getPythonFileIndex`'s `WeakMap` key (built once per run, not * copied per import). The whole resolver chain only reads the set. */ readonly allFilePaths: ReadonlySet; + /** Optional parsed workspace used to preserve a package's explicit export + * when it collides with a same-named concrete submodule. */ + readonly parsedFiles?: readonly ParsedFile[]; } export function resolvePythonImportTarget( @@ -48,6 +51,40 @@ export function resolvePythonImportTarget( if (parsedImport.kind === 'dynamic-unresolved') return null; if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + const submoduleTarget = pythonImportedSubmoduleTarget(parsedImport); + if ( + submoduleTarget !== null && + (parsedImport.kind === 'named' || parsedImport.kind === 'alias') + ) { + // Python's IMPORT_FROM first reads an attribute already exported by the + // package and only loads a same-named submodule when that attribute is + // absent. Preserve that precedence when parsed workspace facts are + // available; the flag suppresses this submodule probe in the recursive + // base-package lookup. + const packageTarget = resolvePythonImportTarget( + { ...parsedImport, targetIncludesImportedName: true }, + workspaceIndex, + ); + if ( + packageTarget !== null && + pythonFileExportsName(packageTarget, parsedImport.importedName, ctx.parsedFiles) + ) { + return packageTarget; + } + + const submodule = resolvePythonImportTarget( + { + kind: 'namespace', + localName: parsedImport.localName, + importedName: parsedImport.importedName, + targetRaw: submoduleTarget, + }, + workspaceIndex, + ); + if (submodule !== null) return submodule; + if (packageTarget !== null) return packageTarget; + } + // PEP-328 relative + single-segment proximity bare imports. const internal = resolvePythonImportInternal( ctx.fromFile, @@ -85,6 +122,22 @@ export function resolvePythonImportTarget( return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile); } +function pythonFileExportsName( + targetFile: string, + importedName: string, + parsedFiles: readonly ParsedFile[] | undefined, +): boolean { + if (parsedFiles === undefined) return false; + const parsed = parsedFiles.find((file) => file.filePath === targetFile); + if (parsed === undefined) return false; + return parsed.localDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined || qualifiedName.length === 0) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === importedName; + }); +} + /** * Resolve `package/sub/module` style paths (already dot-flattened) to a * concrete file in `allFilePaths`. Tries the exact path first, then walks @@ -341,3 +394,48 @@ function getPythonFileIndex(allFilePaths: ReadonlySet): PythonFileIndex PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index); return index; } + +function pythonImportedSubmoduleTarget(parsedImport: ParsedImport): string | null { + if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') return null; + if (parsedImport.targetIncludesImportedName === true) return null; + const separator = parsedImport.targetRaw.endsWith('.') ? '' : '.'; + return parsedImport.targetRaw + separator + parsedImport.importedName; +} + +/** + * A named Python import is a namespace handle only when its resolved file is + * the concrete submodule formed by appending the imported name. This keeps + * ordinary symbol imports on the named-binding path. + */ +export function isPythonImportedModule( + parsedImport: ParsedImport, + targetFile: string, + fromFile: string, +): boolean { + const submoduleTarget = pythonImportedSubmoduleTarget(parsedImport); + if (submoduleTarget === null) return false; + + const normalizedTarget = targetFile.replace(/\\/g, '/'); + let pathLike: string; + + if (submoduleTarget.startsWith('.')) { + const match = submoduleTarget.match(/^(\.+)(.*)$/); + if (match === null) return false; + const ascend = match[1].length - 1; + const base = fromFile.replace(/\\/g, '/').split('/').slice(0, -1); + if (ascend > base.length) return false; + const relativeParts = match[2].split('.').filter(Boolean); + pathLike = [...base.slice(0, base.length - ascend), ...relativeParts].join('/'); + } else { + pathLike = submoduleTarget.replace(/\./g, '/'); + } + + const moduleFile = pathLike + '.py'; + const packageFile = pathLike + '/__init__.py'; + return ( + normalizedTarget === moduleFile || + normalizedTarget === packageFile || + normalizedTarget.endsWith('/' + moduleFile) || + normalizedTarget.endsWith('/' + packageFile) + ); +} diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index dc7e84f647..5cb5bda4db 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -76,7 +76,11 @@ export { getPythonCaptureCacheStats, resetPythonCaptureCacheStats } from './cach export { interpretPythonImport, interpretPythonTypeBinding } from './interpret.js'; export { pythonMergeBindings } from './merge-bindings.js'; export { pythonArityCompatibility } from './arity.js'; -export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js'; +export { + isPythonImportedModule, + resolvePythonImportTarget, + type PythonResolveContext, +} from './import-target.js'; export { pythonBindingScopeFor, pythonFunctionDefinitionLabel, diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 8ede6b3951..4aecc28b4d 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers. import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { pythonProvider } from '../python.js'; import { + isPythonImportedModule, pythonArityCompatibility, pythonMergeBindings, resolvePythonImportTarget, @@ -32,22 +33,29 @@ const pythonScopeResolver: ScopeResolver = { languageProvider: pythonProvider, importEdgeReason: 'python-scope: import', - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { + resolveImportTarget: (targetRaw, fromFile, allFilePaths, _resolutionConfig, context) => { // Pass the orchestrator's stable run-level `ReadonlySet` straight through // (no per-import copy). The Python resolver chain only reads the set, and // `getPythonFileIndex` memoizes its index on the set's identity via a // WeakMap — so the index is built once per run and reused across every // import. Copying here (the previous `new Set(allFilePaths)`) handed a // fresh identity to every import, defeating that cache (PR #1918 review P1). - const ws: PythonResolveContext = { fromFile, allFilePaths }; + const ws: PythonResolveContext = { + fromFile, + allFilePaths, + parsedFiles: context?.parsedFiles, + }; // `WorkspaceIndex` is an opaque `unknown` placeholder in the // shared contract, so `ws` passes structurally without a cast. return resolvePythonImportTarget( - { kind: 'named', localName: '_', importedName: '_', targetRaw }, + context?.parsedImport ?? { kind: 'namespace', localName: '_', importedName: '_', targetRaw }, ws, ); }, + isNamespaceImport: (parsedImport, targetFile, fromFile) => + isPythonImportedModule(parsedImport, targetFile, fromFile), + // Python LEGB precedence: local > import/namespace/reexport > wildcard. // The per-scope id is unused by pythonMergeBindings (tier ordering // is computed purely from BindingRef.origin), so we don't need to diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 449ac47af0..a8f55edc3d 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -150,6 +150,7 @@ export function extract( d.range, d.filePath, d.ownsReceivers, + d.lexicalNames, ); } } @@ -314,6 +315,7 @@ interface ScopeDraft { readonly ownedDefs: SymbolDefinition[]; readonly imports: ImportEdge[]; readonly typeBindings: Map; + readonly lexicalNames?: ReadonlySet; /** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */ readonly ownsReceivers?: ReadonlySet; } @@ -371,6 +373,7 @@ function draftToScope(draft: ScopeDraft): Scope { ownedDefs: Object.freeze(draft.ownedDefs.slice()), imports: Object.freeze(draft.imports.slice()), typeBindings: new Map(draft.typeBindings), + lexicalNames: draft.lexicalNames, ownsReceivers: draft.ownsReceivers, }; } @@ -448,6 +451,7 @@ function pass1BuildScopes( cand.range, filePath, provider.scopeOwnsReceivers?.(cand.match), + parseScopeLexicalNames(cand.match), ), ); stack.push(cand); @@ -494,6 +498,7 @@ function makeDraft( range: Range, filePath: string, ownsReceivers?: ReadonlySet, + lexicalNames?: ReadonlySet, ): ScopeDraft { return { id, @@ -505,10 +510,26 @@ function makeDraft( ownedDefs: [], imports: [], typeBindings: new Map(), + lexicalNames, ownsReceivers, }; } +function parseScopeLexicalNames(match: CaptureMatch): ReadonlySet | undefined { + const raw = match['@scope.lexical-names']?.text; + if (raw === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return undefined; + const names = parsed.filter( + (name): name is string => typeof name === 'string' && name.length > 0, + ); + return names.length > 0 ? new Set(names) : undefined; + } catch { + return undefined; + } +} + // ─── Pass 2: attach declarations + local bindings ────────────────────────── function pass2AttachDeclarations( @@ -1490,6 +1511,7 @@ function rangesEqual(a: Range, b: Range): boolean { * change. */ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ + '@scope.lexical-names', '@declaration.name', '@declaration.qualified_name', '@import.name', 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 6ba945fcf6..1741904641 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -361,6 +361,17 @@ export interface ScopeResolver { context?: ImportResolutionContext, ): string | readonly string[] | null; + /** + * Optionally reclassify an import as a namespace handle after target + * resolution proves the imported name is itself a module. Returning false + * retains ordinary named-binding behavior. + */ + readonly isNamespaceImport?: ( + parsedImport: ParsedImport, + targetFile: string, + fromFile: string, + ) => boolean; + /** * Enumerate names visible through a wildcard import after the target * module scope has been linked. Languages that do not support 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 39a040a7ed..6b77da5ddb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -30,6 +30,7 @@ import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import { findClassBindingInScope, findEnclosingClassDef, + findExportedDef, findExportedDefByName, findReceiverTypeBinding, isClassLike, @@ -100,6 +101,8 @@ interface ResolveCompoundReceiverOptions { * rather than re-declared, so a future sub-field cannot be added there * and silently ignored here (#2708). */ readonly constructionSyntax?: ScopeResolver['constructionSyntax']; + /** Verified namespace handles visible in the current file. */ + readonly namespaceTargets?: ReadonlyMap; /** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when * the language's capture emitter produced one. Present ⇒ the structural fold * is tried before the text cascade; absent ⇒ behaviour is exactly as before. @@ -146,6 +149,42 @@ function escapeForRegExp(literal: string): string { return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** True when a local declaration between the call site and its module scope + * shadows a file-level namespace import with the same name. Namespace targets + * are collected per file, so callers must apply this lexical guard before + * trusting them at an inner scope. */ +function isNamespaceNameShadowed( + namespaceName: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, +): boolean { + let currentId: ScopeId | null = inScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return true; + if ( + scope.kind !== 'Object' && + (scope.bindings.has(namespaceName) || + scope.typeBindings.has(namespaceName) || + scope.lexicalNames?.has(namespaceName) === true || + scope.ownedDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === namespaceName; + })) + ) { + return true; + } + if (scope.kind === 'Module') return false; + currentId = scope.parent; + } + return true; +} + /** * Type of a construction expression's callee — the class it constructs. * @@ -164,6 +203,7 @@ function resolveConstructionExpressionClass( fnExpr: string, inScope: ScopeId, scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, options: ResolveCompoundReceiverOptions, ): SymbolDefinition | undefined { const syntax = options.constructionSyntax; @@ -190,14 +230,30 @@ function resolveConstructionExpressionClass( // name). if (calleeName === undefined || calleeName.length === 0) return undefined; - const direct = findClassBindingInScope(inScope, calleeName, scopes); - if (direct !== undefined && isClassLike(direct.type)) return direct; - // Generic construction — `new Box()` arrives here as `Box`, // which names no class binding. Retry on the base name, the same // normalization `resolveClassBindingForName` in `receiver-bound-calls` // already applies for typed receivers (#2708). const baseName = stripTemplateArguments(calleeName).trim(); + const lastDot = baseName.lastIndexOf('.'); + if (lastDot !== -1) { + const namespaceName = baseName.slice(0, lastDot); + const exportedName = baseName.slice(lastDot + 1); + const namespaceFiles = options.namespaceTargets?.get(namespaceName) ?? []; + // 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; + const namespaceMatches = namespaceFiles + .map((targetFile) => findExportedDef(targetFile, exportedName, index)) + .filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type)); + return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined; + } + } + + const direct = findClassBindingInScope(inScope, calleeName, scopes); + if (direct !== undefined && isClassLike(direct.type)) return direct; + if (baseName.length > 0 && baseName !== calleeName) { const viaBaseName = findClassBindingInScope(inScope, baseName, scopes); if (viaBaseName !== undefined && isClassLike(viaBaseName.type)) return viaBaseName; @@ -206,8 +262,8 @@ function resolveConstructionExpressionClass( // Qualified callee — `new ns.Service()` / `new Outer.Inner()`. Prefer an // unambiguous qualified-name match, then fall back to the trailing simple // name the way receiver resolution does elsewhere (#2708). - const lastDot = baseName.lastIndexOf('.'); if (lastDot === -1) return undefined; + const qualifiedIds = scopes.qualifiedNames.get(baseName); if (qualifiedIds.length === 1) { const qualified = scopes.defs.get(qualifiedIds[0]!); @@ -503,7 +559,7 @@ export function resolveCompoundReceiverClass( // the dot-split below routes it into member resolution (#2708). const keyword = options.constructionSyntax?.keyword; if (keyword !== undefined && new RegExp(`^${escapeForRegExp(keyword)}\\s`).test(fnExpr)) { - return resolveConstructionExpressionClass(fnExpr, inScope, scopes, options); + return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); } const lastDot = fnExpr.lastIndexOf('.'); @@ -525,7 +581,7 @@ export function resolveCompoundReceiverClass( // read a type off; the return-type path above cannot help either, // because a class has no return-type binding. Type it from the // class the callee names (#2708). - return resolveConstructionExpressionClass(fnExpr, inScope, scopes, options); + return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); } // `obj.method()` — resolve obj's class, look up method's return @@ -540,7 +596,15 @@ export function resolveCompoundReceiverClass( options, depth + 1, ); - if (objClass === undefined) return undefined; + if (objClass === undefined) { + // A verified namespace-qualified bare constructor is syntactically + // indistinguishable from an untyped member call here. Only the namespace + // map makes the constructor interpretation safe. + if (options.namespaceTargets?.has(objExpr) === true) { + return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); + } + return undefined; + } // Does `objExpr` name the CLASS ITSELF (`Factory.new`) rather than a // value whose type is that class (`factory.new`)? Only the former is @@ -743,7 +807,13 @@ export function resolveCompoundReceiverClass( // seeded and the whole chain resolved to nothing. A constructed value is an // instance, so `currentIsClassConstant` correctly stays false here. if (currentClass === undefined) { - currentClass = resolveConstructionExpressionClass(headMemberName, inScope, scopes, options); + currentClass = resolveConstructionExpressionClass( + headMemberName, + inScope, + scopes, + index, + options, + ); } for (let i = 1; i < parts.length && currentClass !== undefined; i++) { 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 ee2cd6a217..038ad24373 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 @@ -257,6 +257,7 @@ export function emitReceiverBoundCalls( for (const parsed of parsedFiles) { const namespaceTargets = collectNamespaceTargets(parsed, scopes); + const fileCompoundOpts = { ...compoundOpts, namespaceTargets }; // 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). @@ -409,7 +410,7 @@ export function emitReceiverBoundCalls( index, // Group A: the receiver IS this site's expression, so the site's // captured chain describes it and the structural fold applies. - { ...compoundOpts, receiverChain: site.receiverChain }, + { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); compoundReceiverUnresolved = currentClass === undefined; if (currentClass !== undefined) { @@ -938,7 +939,7 @@ export function emitReceiverBoundCalls( typeRef.declaredAtScope, scopes, index, - compoundOpts, + fileCompoundOpts, ); if (ownerDef === undefined && !typeRef.rawName.includes('(')) { ownerDef = resolveCompoundReceiverClass( @@ -946,7 +947,7 @@ export function emitReceiverBoundCalls( typeRef.declaredAtScope, scopes, index, - compoundOpts, + fileCompoundOpts, ); } if (ownerDef !== undefined) { @@ -1049,7 +1050,7 @@ export function emitReceiverBoundCalls( scopes, index, // Group A, same reasoning as Case 0 above. - { ...compoundOpts, receiverChain: site.receiverChain }, + { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); } if (ownerDef !== undefined) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 435f74354a..aa6268d528 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -605,6 +605,8 @@ export function runScopeResolution( parsedFiles, parsedImport, }), + isNamespaceImport: (parsedImport, targetFile, fromFile) => + provider.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false, expandsWildcardTo: (targetModuleScope) => provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => 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 6ee0e2a16c..f82a01be2f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -15,8 +15,10 @@ * * Next-consumer contract: any language with namespace-style imports * (TypeScript `import * as X`, Java static import, Ruby `require`) - * uses this directly. `ParsedImport.kind === 'namespace'` is the - * cross-language hook. + * uses this directly. The finalized `ImportEdge.kind === 'namespace'` + * classification is authoritative; providers may produce it directly from + * syntax or reclassify a named import after target resolution proves it names + * a module. * * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` * documents that function-local and class-body imports bind to the @@ -43,14 +45,8 @@ export function collectNamespaceTargets( const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; - const namespaceLocals = new Set(); - for (const imp of parsed.parsedImports) { - if (imp.kind === 'namespace') namespaceLocals.add(imp.localName); - } - for (const edge of moduleEdges) { - if (edge.targetFile === null) continue; - if (!namespaceLocals.has(edge.localName)) continue; + if (edge.targetFile === null || edge.kind !== 'namespace') continue; let targets = out.get(edge.localName); if (targets === undefined) { targets = []; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 1f1808f66f..6b8b6f19bb 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -636,8 +636,13 @@ export interface RepoMeta { * multi-line closure binding joins the scope channel and emits its CALLS edge. * Pre-v30 indexes keep the wrapper line on unchanged files and would keep * failing closed (no edge) through the reuse gate. Force a full re-analyze. + * + * v31: Python named imports that resolve to concrete submodules are finalized + * as namespace edges (#2746), enabling qualified constructor and method CALLS + * edges. Pre-v31 indexes retain the old package-target/missing-edge graph for + * unchanged files through the reuse gate. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 30; +export const INCREMENTAL_SCHEMA_VERSION = 31; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py new file mode 100644 index 0000000000..946dfdf8fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py @@ -0,0 +1,3 @@ +class User: + def save(self): + return "wrong" diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py new file mode 100644 index 0000000000..7338fd54fb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py @@ -0,0 +1,15 @@ +from pkg import models +from pkg.models import User + + +def inline_save(db): + return models.User(db).save() + + +def assigned_save(db): + user = models.User(db) + return user.save() + + +def direct_save(db): + return User(db).save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py new file mode 100644 index 0000000000..0a11644b08 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py @@ -0,0 +1,6 @@ +class User: + def __init__(self, db): + self.db = db + + def save(self): + return self.db diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 86af1fb871..e9131d7482 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -331,6 +331,22 @@ "captureGroups": 41, "digest": "e8807a9969732197feb04204d200d5810b895c006f1424a7a6f5f0d5762ef49a" }, + "python-from-module-alias/decoy/models.py": { + "captureGroups": 7, + "digest": "bc8f2332e9a4a683f3fdae966a0d8a8f534a310ddc438a7a4ebc040e97d642b8" + }, + "python-from-module-alias/pkg/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-from-module-alias/pkg/app.py": { + "captureGroups": 23, + "digest": "afb50d3e98def472f9215e986dd4f964eedd197a9acfc8048d92b882e8fe868a" + }, + "python-from-module-alias/pkg/models.py": { + "captureGroups": 14, + "digest": "fe52542a27d98c57d7acd48212dc301bdb448ad5868afb68c98cede9299dd1e5" + }, "python-function-local-import-chain/app.py": { "captureGroups": 9, "digest": "ad4d45976ca10c3fc3b7bf498ee23397797368a5d2817311d0e95e943af89199" diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index a6405247ac..46adea764f 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -2336,6 +2336,44 @@ describe('Python module import CALLS resolution (Issue #337)', () => { }); }); +// --------------------------------------------------------------------------- +// Module reached through `from pkg import models` (#2746) +// --------------------------------------------------------------------------- + +describe('Python from-import module alias resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'python-from-module-alias'), () => {}); + }, 60000); + + it('links the imported module rather than the package initializer', () => { + const imports = getRelationships(result, 'IMPORTS'); + const appImports = imports.filter((edge) => edge.sourceFilePath === 'pkg/app.py'); + + expect(appImports.length).toBeGreaterThan(0); + expect(appImports.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true); + }); + + it('resolves inline and assigned calls through the module alias', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.sourceFilePath === 'pkg/app.py', + ); + + expect(calls.filter((edge) => edge.target === 'User')).toHaveLength(2); + expect(calls.filter((edge) => edge.target === 'save')).toHaveLength(3); + expect(calls.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true); + }); + + it('does not bind the same-named class from an unrelated module', () => { + const wrongCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.sourceFilePath === 'pkg/app.py' && edge.targetFilePath === 'decoy/models.py', + ); + + expect(wrongCalls).toHaveLength(0); + }); +}); + // --------------------------------------------------------------------------- // External dotted imports: framework modules like django.apps must not resolve // to unrelated local basename matches such as accounts/apps.py or config/urls.py. diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 146239be30..1adbb2a7e1 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 30 (multi-line closure startLine join, #2735)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 31 (Python module-import resolution, #2746)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(30); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(31); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -205,7 +205,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // A pre-v30 (v29) index keeps wrapper-line startLines for multi-line closure // bindings (#2735), so the graph-to-scope join still drops the CALLS edge. expect(passesReuseGate(29)).toBe(false); + // A pre-v31 (v30) index treats `from pkg import models` as a named package + // import, so unchanged files retain the old missing qualified CALLS edges. + expect(passesReuseGate(30)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(30)).toBe(true); + expect(passesReuseGate(31)).toBe(true); }); }); diff --git a/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts index 4118461fb9..02cc1355f7 100644 --- a/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts +++ b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts @@ -160,6 +160,34 @@ describe('finalizeScopeModel: cross-file imports', () => { expect(appImports[0]!.targetDefId).toBe('def:User'); }); + it('finalizes a provider-classified module symbol as a namespace edge', () => { + const modelsFile = mkFile('pkg/models.py', { + localDefs: [mkDef('def:User', 'pkg/models.py', 'pkg.models.User')], + }); + const appFile = mkFile('pkg/app.py', { + parsedImports: [ + { + kind: 'named', + localName: 'models', + importedName: 'models', + targetRaw: 'pkg', + }, + ], + }); + + const out = finalizeScopeModel([appFile, modelsFile], { + hooks: { + resolveImportTarget: () => 'pkg/models.py', + isNamespaceImport: () => true, + }, + }); + + const edge = out.imports.get(appFile.moduleScope)?.[0]; + expect(edge?.kind).toBe('namespace'); + expect(edge?.linkStatus).toBeUndefined(); + expect(edge?.targetModuleScope).toBe(modelsFile.moduleScope); + expect(out.stats.unresolvedEdges).toBe(0); + }); it('leaves imports unresolved when no resolveImportTarget is supplied (default hook)', () => { // Default `resolveImportTarget: () => null` — every import ends up // with `linkStatus: 'unresolved'`. This is the zero-provider case diff --git a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts index f4fb3e7564..7944b82d5d 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts @@ -53,6 +53,10 @@ type Snapshot = Record; function canonicalizeMatch(match: CaptureMatch): string { const parts: string[] = []; for (const tag of Object.keys(match)) { + // Scope-only lexical shadow metadata has dedicated extraction/resolution + // tests. Exclude it from the historical capture fingerprint so adding + // untyped parameter barriers does not rebaseline every Python fixture. + if (tag === '@scope.lexical-names') continue; const cap = match[tag]!; const r = cap.range; parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`); diff --git a/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts b/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts index f52b8eed26..658221bfef 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts @@ -11,13 +11,20 @@ * index regression fails CI rather than silently changing resolved edges. */ import { describe, it, expect } from 'vitest'; -import { resolvePythonImportTarget } from '../../../../src/core/ingestion/languages/python/index.js'; -import type { ParsedImport } from 'gitnexus-shared'; +import { + isPythonImportedModule, + resolvePythonImportTarget, +} from '../../../../src/core/ingestion/languages/python/index.js'; +import type { ParsedFile, ParsedImport } from 'gitnexus-shared'; function mkImport(targetRaw: string): ParsedImport { return { kind: 'absolute', targetRaw, isRelative: false, names: [] } as unknown as ParsedImport; } +function mkNamed(targetRaw: string, importedName: string): ParsedImport { + return { kind: 'named', targetRaw, localName: importedName, importedName }; +} + function resolve(fromFile: string, files: string[], targetRaw: string): string | null { return resolvePythonImportTarget(mkImport(targetRaw), { fromFile, @@ -150,4 +157,64 @@ describe('resolvePythonImportTarget — index parity', () => { // A package whose only file is non-.py is not a repo candidate → null. expect(resolve('app/main.py', [...files, 'tsonly/widget.ts'], 'tsonly.widget')).toBeNull(); }); + + it('prefers a concrete imported submodule over the package initializer (#2746)', () => { + const parsed = mkNamed('pkg', 'models'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + }); + + expect(target).toBe('pkg/models.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(true); + }); + + it('preserves an explicit package export over a same-named submodule', () => { + const parsed = mkNamed('pkg', 'models'); + const packageFile = { + filePath: 'pkg/__init__.py', + localDefs: [{ qualifiedName: 'models' }], + } as unknown as ParsedFile; + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + parsedFiles: [packageFile], + }); + + expect(target).toBe('pkg/__init__.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false); + }); + + it('keeps ordinary symbols on the named-import path', () => { + const parsed = mkNamed('pkg.models', 'User'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + }); + + expect(target).toBe('pkg/models.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false); + }); + + it('recognizes relative from-import submodules', () => { + const parsed = mkNamed('.', 'models'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py', 'pkg/models.py']), + }); + + expect(target).toBe('pkg/models.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(true); + }); + + it('falls back to a package export when no same-named submodule exists', () => { + const parsed = mkNamed('pkg', 'exported'); + const target = resolvePythonImportTarget(parsed, { + fromFile: 'pkg/app.py', + allFilePaths: new Set(['pkg/__init__.py']), + }); + + expect(target).toBe('pkg/__init__.py'); + expect(isPythonImportedModule(parsed, target ?? '', 'pkg/app.py')).toBe(false); + }); }); 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 new file mode 100644 index 0000000000..75b3303c48 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { pythonScopeResolver } from '../../../../src/core/ingestion/languages/python/scope-resolver.js'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js'; +import { resolveCompoundReceiverClass } from '../../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js'; +import { collectNamespaceTargets } from '../../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js'; +import { buildWorkspaceResolutionIndex } from '../../../../src/core/ingestion/scope-resolution/workspace-index.js'; + +const SOURCES = new Map([ + [ + 'pkg/app.py', + `from pkg import models +from decoy import Missing + +def valid(): + return models.User() + +def missing(): + return models.Missing() + +def shadowed(models): + return models.User() + +def locally_shadowed(): + models = object() + return models.User() +`, + ], + [ + 'pkg/models.py', + `class User: + pass +`, + ], + ['pkg/__init__.py', '# package marker\n'], + [ + 'decoy.py', + `class Missing: + pass +`, + ], +]); + +function build() { + const parsedFiles: ParsedFile[] = []; + for (const [filePath, source] of SOURCES) { + const parsed = extractParsedFile(pythonScopeResolver.languageProvider, source, filePath); + if (parsed === undefined) throw new Error(`scope extraction failed for ${filePath}`); + pythonScopeResolver.populateOwners(parsed); + parsedFiles.push(parsed); + } + + const allFilePaths = new Set(parsedFiles.map((file) => file.filePath)); + const scopes = finalizeScopeModel(parsedFiles, { + hooks: { + resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => + pythonScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths, undefined, { + parsedFiles, + parsedImport, + }), + isNamespaceImport: (parsedImport, targetFile, fromFile) => + pythonScopeResolver.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false, + mergeBindings: (existing, incoming, scopeId) => + pythonScopeResolver.mergeBindings(existing, incoming, scopeId), + }, + }); + const index = buildWorkspaceResolutionIndex(parsedFiles); + const app = parsedFiles.find((file) => file.filePath === 'pkg/app.py'); + if (app === undefined) throw new Error('missing app fixture'); + const namespaceTargets = collectNamespaceTargets(app, scopes); + + const resolveIn = (functionName: string, expression: string) => { + const functionScope = app.scopes.find( + (scope) => + scope.kind === 'Function' && + scope.ownedDefs.some((def) => def.qualifiedName === functionName), + ); + if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`); + return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, { + constructionSyntax: { bare: true }, + namespaceTargets, + }); + }; + + return { resolveIn }; +} + +describe('Python module namespace construction', () => { + const { resolveIn } = build(); + + it('resolves an exported class from the verified module target', () => { + expect(resolveIn('valid', 'models.User()')).toMatchObject({ + filePath: 'pkg/models.py', + qualifiedName: 'User', + }); + }); + + it('fails closed when the module lacks the requested class', () => { + expect(resolveIn('missing', 'models.Missing()')).toBeUndefined(); + }); + + it('does not reuse a file-level namespace when a parameter shadows it', () => { + expect(resolveIn('shadowed', 'models.User()')).toBeUndefined(); + }); + + it('does not reuse a file-level namespace when a local shadows it', () => { + expect(resolveIn('locally_shadowed', 'models.User()')).toBeUndefined(); + }); +});