mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
fix(python): resolve classes through module imports (#2770)
This commit is contained in:
parent
e9a4c27ed1
commit
01c8a94fdf
29 changed files with 575 additions and 35 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483
|
||||
b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/decoy/models.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class User:
|
||||
def save(self):
|
||||
return "wrong"
|
||||
0
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py
vendored
Normal file
0
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/__init__.py
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/app.py
vendored
Normal 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()
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-from-module-alias/pkg/models.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class User:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def save(self):
|
||||
return self.db
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue