fix(python): resolve classes through module imports (#2770)

This commit is contained in:
azizur100389 2026-08-01 06:02:47 +01:00 • committed by GitHub
parent e9a4c27ed1
commit 01c8a94fdf
29 changed files with 575 additions and 35 deletions

View file

@ -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<DefId, DefId[]>` — 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 |

View file

@ -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,

View file

@ -415,6 +415,11 @@ export interface Scope {
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
readonly typeBindings: ReadonlyMap<string, TypeRef>;
/** 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<string>;
/** Receiver names this scope BINDS rather than inherits — `this`, `self`, … (#2701).
*
* A receiver walk (`findReceiverTypeBinding`) that reaches such a scope

View file

@ -1 +1 @@
ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483
b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2

View file

@ -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}`);

View file

@ -201,6 +201,7 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) {
function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
return {
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
isNamespaceImport: partial.isNamespaceImport,
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
mergeBindings:
partial.mergeBindings ??

View file

@ -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),
};
}

View file

@ -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));

View file

@ -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<string>;
/** 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<string>): 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)
);
}

View file

@ -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,

View file

@ -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

View file

@ -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<string, TypeRef>;
readonly lexicalNames?: ReadonlySet<string>;
/** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */
readonly ownsReceivers?: ReadonlySet<string>;
}
@ -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<string>,
lexicalNames?: ReadonlySet<string>,
): ScopeDraft {
return {
id,
@ -505,10 +510,26 @@ function makeDraft(
ownedDefs: [],
imports: [],
typeBindings: new Map(),
lexicalNames,
ownsReceivers,
};
}
function parseScopeLexicalNames(match: CaptureMatch): ReadonlySet<string> | 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<string> = new Set<string>([
'@scope.lexical-names',
'@declaration.name',
'@declaration.qualified_name',
'@import.name',

View file

@ -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

View file

@ -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<string, readonly string[]>;
/** 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<ScopeId>();
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<string>()` arrives here as `Box<string>`,
// 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++) {

View file

@ -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) {

View file

@ -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) =>

View file

@ -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<string>();
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 = [];

View file

@ -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;

View file

@ -0,0 +1,3 @@
class User:
def save(self):
return "wrong"

View file

@ -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()

View file

@ -0,0 +1,6 @@
class User:
def __init__(self, db):
self.db = db
def save(self):
return self.db

View file

@ -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"

View file

@ -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.

View file

@ -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);
});
});

View file

@ -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

View file

@ -53,6 +53,10 @@ type Snapshot = Record<string, FixtureSnapshot>;
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}`);

View file

@ -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);
});
});

View file

@ -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();
});
});