mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
fix(python): resolve calls through aliased package re-exports (#3504)
This commit is contained in:
parent
4ccba12004
commit
ff922c0a3b
32 changed files with 1427 additions and 170 deletions
|
|
@ -70,6 +70,9 @@ export interface FinalizeFile {
|
|||
* static input.
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
/** Exact module bindings when the provider can distinguish them from nested
|
||||
* declarations. Absent preserves the legacy definition-name lookup. */
|
||||
readonly localExports?: ReadonlyMap<string, SymbolDefinition>;
|
||||
/**
|
||||
* The extractor's actual lexical bindings for {@link moduleScope}.
|
||||
*
|
||||
|
|
@ -134,6 +137,14 @@ export interface FinalizeHooks {
|
|||
*/
|
||||
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
|
||||
|
||||
/** Opt into wildcard expansion over local and transitively imported exports.
|
||||
* The same filter governs closure propagation and binding materialization. */
|
||||
readonly filterWildcardNames?: (
|
||||
targetModuleScope: ScopeId,
|
||||
availableNames: readonly string[],
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
) => readonly string[];
|
||||
|
||||
/**
|
||||
* Does this language make two `wildcard` re-exports that both DECLARE the
|
||||
* same name AMBIGUOUS (no winner), rather than overloads or redeclarations
|
||||
|
|
@ -317,6 +328,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
moduleEdgeIndex,
|
||||
ambiguousByFile,
|
||||
topLevelOnly,
|
||||
hooks.filterWildcardNames,
|
||||
input.workspaceIndex,
|
||||
);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
|
|
@ -404,7 +417,13 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
}
|
||||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
const expanded = expandWildcard(
|
||||
edge,
|
||||
byFilePath,
|
||||
hooks,
|
||||
input.workspaceIndex,
|
||||
reexportClosures,
|
||||
);
|
||||
for (const e of expanded) {
|
||||
if (
|
||||
d.fromScope === file.moduleScope &&
|
||||
|
|
@ -506,6 +525,9 @@ function makeEdgeDrafts(
|
|||
targetFile: null,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
...(parsed.kind === 'namespace' && parsed.explicitAlias === true
|
||||
? { explicitAlias: true }
|
||||
: {}),
|
||||
...typeOnlyFor(parsed),
|
||||
...runsOnlyWhenCalledFor(parsed),
|
||||
linkStatus: 'unresolved',
|
||||
|
|
@ -544,6 +566,9 @@ function makeEdgeDrafts(
|
|||
hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true
|
||||
? 'namespace'
|
||||
: edgeKindFor(parsed),
|
||||
...(parsed.kind === 'namespace' && parsed.explicitAlias === true
|
||||
? { explicitAlias: true }
|
||||
: {}),
|
||||
...typeOnlyFor(parsed),
|
||||
...runsOnlyWhenCalledFor(parsed),
|
||||
};
|
||||
|
|
@ -687,7 +712,7 @@ function tryFinalize(
|
|||
// a failure.
|
||||
if (draft.base.kind === 'namespace') {
|
||||
const moduleDef = findExportByName(
|
||||
targetModule.localDefs,
|
||||
targetModule,
|
||||
extractExportedName(draft.source),
|
||||
topLevelOnly,
|
||||
);
|
||||
|
|
@ -702,7 +727,7 @@ function tryFinalize(
|
|||
// local defs. Multi-hop re-export chains settle iteratively — each hop
|
||||
// resolves once its prior hop is finalized.
|
||||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
|
||||
const exported = findExportByName(targetModule, importedName, topLevelOnly);
|
||||
|
||||
if (exported !== undefined) {
|
||||
const transitiveVia =
|
||||
|
|
@ -831,6 +856,8 @@ function buildReexportClosures(
|
|||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
ambiguous: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
topLevelOnly: boolean,
|
||||
filterWildcardNames: FinalizeHooks['filterWildcardNames'],
|
||||
workspace: WorkspaceIndex,
|
||||
): ReadonlyMap<string, FileReexportClosure> {
|
||||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||||
for (const file of files) closures.set(file.filePath, new Map());
|
||||
|
|
@ -866,7 +893,16 @@ function buildReexportClosures(
|
|||
if (!scc.isCycle) {
|
||||
const filePath = scc.files[0];
|
||||
if (filePath !== undefined) {
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly);
|
||||
populateFileClosure(
|
||||
filePath,
|
||||
byFilePath,
|
||||
edgeIndex,
|
||||
closures,
|
||||
ambiguous,
|
||||
topLevelOnly,
|
||||
filterWildcardNames,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -881,7 +917,16 @@ function buildReexportClosures(
|
|||
iter++;
|
||||
for (const filePath of scc.files) {
|
||||
if (
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly)
|
||||
populateFileClosure(
|
||||
filePath,
|
||||
byFilePath,
|
||||
edgeIndex,
|
||||
closures,
|
||||
ambiguous,
|
||||
topLevelOnly,
|
||||
filterWildcardNames,
|
||||
workspace,
|
||||
)
|
||||
) {
|
||||
progressed = true;
|
||||
}
|
||||
|
|
@ -1021,7 +1066,7 @@ function namedReexportCandidates(
|
|||
if (targetFile === null) continue;
|
||||
const target = byFilePath.get(targetFile);
|
||||
if (target === undefined) continue;
|
||||
const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly);
|
||||
const def = findExportByName(target, draft.source.importedName, topLevelOnly);
|
||||
if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId);
|
||||
}
|
||||
return Object.freeze(ids);
|
||||
|
|
@ -1216,6 +1261,8 @@ function populateFileClosure(
|
|||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||||
ambiguousByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
topLevelOnly: boolean,
|
||||
filterWildcardNames: FinalizeHooks['filterWildcardNames'],
|
||||
workspace: WorkspaceIndex,
|
||||
): boolean {
|
||||
const myClosure = closures.get(filePath);
|
||||
if (myClosure === undefined) return false;
|
||||
|
|
@ -1240,7 +1287,7 @@ function populateFileClosure(
|
|||
if (ambiguous.has(localName) || myClosure.has(localName)) continue;
|
||||
|
||||
const importedName = draft.source.importedName;
|
||||
const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
|
||||
const direct = findExportByName(targetModule, importedName, topLevelOnly);
|
||||
if (direct !== undefined) {
|
||||
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
|
||||
continue;
|
||||
|
|
@ -1283,15 +1330,26 @@ function populateFileClosure(
|
|||
// named-import path: only a language that opted in (ECMAScript, where
|
||||
// `export *` cannot publish a class member) narrows; every other language's
|
||||
// wildcard keeps the wide index, whose members are legitimately reachable.
|
||||
for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(
|
||||
targetModule.localDefs,
|
||||
)) {
|
||||
const localExports = localExportsByName(targetModule, topLevelOnly);
|
||||
const targetClosure = closures.get(targetFile);
|
||||
const allowedNames =
|
||||
filterWildcardNames === undefined
|
||||
? undefined
|
||||
: new Set(
|
||||
filterWildcardNames(
|
||||
targetModule.moduleScope,
|
||||
[...new Set([...localExports.keys(), ...(targetClosure?.keys() ?? [])])],
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
for (const [name, def] of localExports) {
|
||||
if (allowedNames !== undefined && !allowedNames.has(name)) continue;
|
||||
if (ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||||
}
|
||||
const targetClosure = closures.get(targetFile);
|
||||
if (targetClosure !== undefined) {
|
||||
for (const [name, entry] of targetClosure) {
|
||||
if (allowedNames !== undefined && !allowedNames.has(name)) continue;
|
||||
if (ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
myClosure.set(name, {
|
||||
def: entry.def,
|
||||
|
|
@ -1366,7 +1424,7 @@ function deriveSimpleName(def: SymbolDefinition): string | null {
|
|||
}
|
||||
|
||||
function findExportByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
file: FinalizeFile,
|
||||
name: string,
|
||||
/**
|
||||
* `true` (a `namedImportsBindTopLevelOnly` language): consult only
|
||||
|
|
@ -1400,7 +1458,17 @@ function findExportByName(
|
|||
//
|
||||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||||
// for the cross-file regression this rule prevents.
|
||||
return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name);
|
||||
return localExportsByName(file, topLevelOnly).get(name);
|
||||
}
|
||||
|
||||
function localExportsByName(
|
||||
file: FinalizeFile,
|
||||
topLevelOnly: boolean,
|
||||
): ReadonlyMap<string, SymbolDefinition> {
|
||||
return (
|
||||
file.localExports ??
|
||||
(topLevelOnly ? indexTopLevelExportsByName(file.localDefs) : indexExportsByName(file.localDefs))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1531,6 +1599,7 @@ function expandWildcard(
|
|||
byFilePath: Map<string, FinalizeFile>,
|
||||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
|
||||
): readonly ImportEdge[] {
|
||||
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
|
||||
return [edge]; // unresolvable wildcard survives as a single unlinked edge
|
||||
|
|
@ -1538,7 +1607,14 @@ function expandWildcard(
|
|||
const target = byFilePath.get(edge.targetFile);
|
||||
if (target === undefined) return [edge];
|
||||
|
||||
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
|
||||
const targetClosure =
|
||||
hooks.filterWildcardNames === undefined ? undefined : reexportClosures.get(edge.targetFile);
|
||||
const names =
|
||||
hooks.filterWildcardNames?.(
|
||||
edge.targetModuleScope,
|
||||
[...new Set([...localExportsByName(target, false).keys(), ...(targetClosure?.keys() ?? [])])],
|
||||
workspace,
|
||||
) ?? hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
|
||||
if (names.length === 0) {
|
||||
// Resolved wildcard with zero propagating names is still a real file-
|
||||
// level dependency (e.g. a C++ header that only declares classes —
|
||||
|
|
@ -1551,8 +1627,10 @@ function expandWildcard(
|
|||
}
|
||||
|
||||
const expanded: ImportEdge[] = [];
|
||||
const localExports = localExportsByName(target, false);
|
||||
for (const name of names) {
|
||||
const def = findExportByName(target.localDefs, name);
|
||||
const inherited = targetClosure?.get(name);
|
||||
const def = localExports.get(name) ?? inherited?.def;
|
||||
if (def === undefined) continue;
|
||||
expanded.push({
|
||||
localName: name,
|
||||
|
|
@ -1561,6 +1639,9 @@ function expandWildcard(
|
|||
kind: 'wildcard-expanded',
|
||||
targetModuleScope: edge.targetModuleScope,
|
||||
targetDefId: def.nodeId,
|
||||
...(localExports.has(name) || inherited === undefined
|
||||
? {}
|
||||
: { transitiveVia: extendVia(edge.targetFile, inherited.via) }),
|
||||
// Every expanded edge inherits the presence facts of the ONE statement it
|
||||
// came from. They are built fresh rather than spread from `edge` because
|
||||
// `localName`, `targetExportedName` and `targetDefId` all differ per name
|
||||
|
|
|
|||
|
|
@ -305,6 +305,8 @@ type ParsedImportSyntax =
|
|||
readonly kind: 'namespace';
|
||||
/** The source omitted a local alias; the imported module declares its binding name. */
|
||||
readonly implicitLocalName?: boolean;
|
||||
/** An explicit alias binds the imported module, even when it matches the path's root. */
|
||||
readonly explicitAlias?: boolean;
|
||||
/** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */
|
||||
readonly localName: string;
|
||||
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */
|
||||
|
|
@ -526,6 +528,8 @@ export interface Callsite {
|
|||
export interface ImportEdge {
|
||||
/** How this scope sees the imported name (after alias). */
|
||||
readonly localName: string;
|
||||
/** Namespace-import alias syntax, retained for providers whose unaliased imports bind a path prefix. */
|
||||
readonly explicitAlias?: boolean;
|
||||
/** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */
|
||||
readonly targetFile: string | null;
|
||||
/** The name under which the target exports this symbol. */
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1ab17d9cb80c3863ac9df17e4eefe4983a21f8d9908c110e8a8d9fa1b35efcc0
|
||||
b6754e5362716211a348f3aafda8bbc00082e3fb0e45cb3aa79a1be5e3df1a8b
|
||||
|
|
|
|||
|
|
@ -47,12 +47,12 @@
|
|||
* set moved, which is a behaviour change to be explained, never re-baselined to
|
||||
* make CI green.
|
||||
*
|
||||
* WHY A ZIG CORPUS for a language-neutral pass. Zig is the only language whose
|
||||
* provider sets `namespaceExportsIncludeImportedNames`, so it is the only one
|
||||
* that can exercise channel 3 at all; and the file-as-struct idiom puts channels
|
||||
* 2 and 4 in one file, which is the shape #3399 was filed over. The corpus also
|
||||
* carries two DECLINE controls — a non-callable namespace member and a
|
||||
* non-callable bare argument — so a change that widened the callable gate would
|
||||
* WHY A ZIG CORPUS for a language-neutral pass. Python and Zig both opt into
|
||||
* imported-name namespace lookup. Zig's hub re-exports exercise channel 3,
|
||||
* and its file-as-struct idiom puts channels 2 and 4 in one file, which is the
|
||||
* shape #3399 was filed over. The corpus also carries two DECLINE controls —
|
||||
* a non-callable namespace member and a non-callable bare argument — so a
|
||||
* change that widened the callable gate would
|
||||
* move `declined` rather than hiding inside the timing.
|
||||
*
|
||||
* Container naming is load-bearing in the corpus: a Zig file-as-struct is minted
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ export interface FinalizeOrchestratorOptions {
|
|||
* merge for bindings.
|
||||
*/
|
||||
readonly hooks?: Partial<FinalizeHooks>;
|
||||
/** Optional exact module export surface, independent of nested definitions. */
|
||||
readonly moduleExports?: (file: ParsedFile) => ReadonlyMap<string, SymbolDefinition>;
|
||||
/**
|
||||
* Opaque workspace context forwarded to hooks. `undefined` today; Ring
|
||||
* 2 PKG #922 populates this with a real cross-file index for the
|
||||
|
|
@ -95,7 +97,7 @@ export function finalizeScopeModel(
|
|||
// materialization. Returns linked imports + merged bindings per module
|
||||
// scope + SCC condensation + stats.
|
||||
const finalizeInput = {
|
||||
files: parsedFiles.map(toFinalizeFile),
|
||||
files: parsedFiles.map((file) => toFinalizeFile(file, options.moduleExports?.(file))),
|
||||
workspaceIndex,
|
||||
};
|
||||
const finalizeOut = finalize(finalizeInput, hooks);
|
||||
|
|
@ -165,8 +167,11 @@ export function finalizeScopeModel(
|
|||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Project a `ParsedFile` into the narrower `FinalizeFile` shape. */
|
||||
function toFinalizeFile(file: ParsedFile): FinalizeFile {
|
||||
/** Shape-reduce parse output and attach the provider's exact export surface. */
|
||||
function toFinalizeFile(
|
||||
file: ParsedFile,
|
||||
localExports?: ReadonlyMap<string, SymbolDefinition>,
|
||||
): FinalizeFile {
|
||||
const moduleScope = file.scopes.find((scope) => scope.id === file.moduleScope);
|
||||
const moduleBindings = new Map(moduleScope?.bindings ?? []);
|
||||
// Namespace members historically participate in the same-file unqualified
|
||||
|
|
@ -183,6 +188,7 @@ function toFinalizeFile(file: ParsedFile): FinalizeFile {
|
|||
moduleScope: file.moduleScope,
|
||||
parsedImports: file.parsedImports,
|
||||
localDefs: file.localDefs,
|
||||
localExports,
|
||||
// The extractor always emits the module scope. Its binding map is the
|
||||
// authoritative lexical surface; `ownedDefs` is structural ownership and
|
||||
// excludes hoisted top-level function/class declarations.
|
||||
|
|
@ -220,6 +226,7 @@ function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
|
|||
wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true,
|
||||
namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true,
|
||||
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
|
||||
filterWildcardNames: partial.filterWildcardNames,
|
||||
mergeBindings:
|
||||
partial.mergeBindings ??
|
||||
((
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
const namesByWorkspace = new WeakMap<
|
||||
readonly ParsedFile[],
|
||||
ReadonlyMap<ScopeId, readonly string[] | null>
|
||||
>();
|
||||
|
||||
/** Keep each module binding's definition, rather than looking its name up again
|
||||
* among class members and nested functions in the file's flat definition list. */
|
||||
export function pythonModuleExports(parsed: ParsedFile): ReadonlyMap<string, SymbolDefinition> {
|
||||
const exports = new Map<string, SymbolDefinition>();
|
||||
const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope);
|
||||
for (const [name, bindings] of module?.bindings ?? []) {
|
||||
const definition = bindings[0]?.def;
|
||||
if (
|
||||
definition !== undefined &&
|
||||
bindings.every((binding) => binding.def.nodeId === definition.nodeId)
|
||||
) {
|
||||
exports.set(name, definition);
|
||||
}
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
/** Public declarations imported by `from module import *`.
|
||||
* Explicit `__all__` values are not represented by ParsedFile. Decline those
|
||||
* modules rather than assume that every public declaration was exported.
|
||||
*/
|
||||
export function expandPythonWildcardNames(
|
||||
targetModuleScope: ScopeId,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
availableNames?: readonly string[],
|
||||
): readonly string[] {
|
||||
let byScope = namesByWorkspace.get(parsedFiles);
|
||||
if (byScope === undefined) {
|
||||
const collected = new Map<ScopeId, readonly string[] | null>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope);
|
||||
// Scope-creating declarations are owned by their body scope, but bind
|
||||
// in the parent. The parsed module's binding keys preserve that rule.
|
||||
const names = [...(module?.bindings.keys() ?? [])];
|
||||
const hasExplicitExports =
|
||||
names.includes('__all__') ||
|
||||
parsed.parsedImports.some(
|
||||
(edge) =>
|
||||
'localName' in edge &&
|
||||
edge.localName === '__all__' &&
|
||||
(edge.declaredAtScope === undefined || edge.declaredAtScope === parsed.moduleScope),
|
||||
);
|
||||
collected.set(parsed.moduleScope, hasExplicitExports ? null : names);
|
||||
}
|
||||
byScope = collected;
|
||||
namesByWorkspace.set(parsedFiles, byScope);
|
||||
}
|
||||
const localNames = byScope.get(targetModuleScope);
|
||||
if (localNames == null) return [];
|
||||
return [
|
||||
...new Set(
|
||||
(availableNames ?? localNames).filter((name) => name.length > 0 && !name.startsWith('_')),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -464,11 +464,12 @@ export function isPythonImportedModule(
|
|||
* `a.helper()` resolved into `a/b/c.py` whenever that module happened to export
|
||||
* `helper`, and `a.b.mid()` resolved to nothing.
|
||||
*
|
||||
* Returns `undefined` — meaning "use the shared default" — for every spelling
|
||||
* where the bound name is not the path's root:
|
||||
* Returns `undefined` — meaning "use the shared default" — for aliases and
|
||||
* spellings where the bound name is not the path's root:
|
||||
* - `import single` — no dotted path to expand;
|
||||
* - `import a.b as x` — binds only `x`; writing `a.b.f()` there is a
|
||||
* NameError, so `a.b` must NOT become a key;
|
||||
* - `import a.b as a` — also binds the leaf, despite the root spelling;
|
||||
* - `from pkg import db` — reclassified to a namespace edge whose
|
||||
* importPath is the bare name `db`.
|
||||
*
|
||||
|
|
@ -477,9 +478,15 @@ export function isPythonImportedModule(
|
|||
* `__init__.py`) contributes no key rather than one pointing at a missing file.
|
||||
*/
|
||||
export function pythonNamespaceReceiverPaths(
|
||||
edge: { readonly localName: string; readonly importPath: string; readonly targetFile: string },
|
||||
edge: {
|
||||
readonly localName: string;
|
||||
readonly importPath: string;
|
||||
readonly targetFile: string;
|
||||
readonly explicitAlias?: boolean;
|
||||
},
|
||||
moduleFileExists: (filePath: string) => boolean,
|
||||
): readonly (readonly [string, string])[] | undefined {
|
||||
if (edge.explicitAlias === true) return undefined;
|
||||
const segments = edge.importPath.split('.');
|
||||
if (segments.length < 2) return undefined;
|
||||
if (segments[0] !== edge.localName) return undefined;
|
||||
|
|
@ -498,26 +505,37 @@ export function pythonNamespaceReceiverPaths(
|
|||
// POSIX-vs-Windows probing is needed: workspace paths are not normalized at
|
||||
// ingestion, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`.
|
||||
const dirs = edge.targetFile.split('/').slice(0, -1);
|
||||
// The import's leading segments name the leaf's innermost directories.
|
||||
const offset = dirs.length - (segments.length - 1);
|
||||
// A package's final segment names its directory; a module's names its file.
|
||||
const importedDirectoryCount = edge.targetFile.endsWith('/__init__.py')
|
||||
? segments.length
|
||||
: segments.length - 1;
|
||||
const offset = dirs.length - importedDirectoryCount;
|
||||
if (offset < 0) return out;
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const spelling = segments.slice(0, i).join('.');
|
||||
const packageFile = dirs.slice(0, offset + i).join('/') + '/__init__.py';
|
||||
// Package FIRST, then the leaf as a fallback — order is the whole point.
|
||||
//
|
||||
// `findExportedDef` only accepts a binding whose `origin === 'local'`, and
|
||||
// the canonical package re-exports (`from .b.c import helper` in
|
||||
// `__init__.py`) produce an IMPORT binding. Keying the prefix at the
|
||||
// package alone therefore loses `a.helper()` entirely for the most common
|
||||
// package shape — the fixtures here all define members locally in
|
||||
// `__init__.py`, which is precisely the one layout where that mistake is
|
||||
// invisible. Keeping the leaf behind the package restores that resolution
|
||||
// while still letting a real definition in `__init__.py` win over a
|
||||
// same-named decoy deeper in the package.
|
||||
// Imported members resolve through the package's own bindings. The leaf
|
||||
// must not publish its members under an intermediate package's name.
|
||||
if (moduleFileExists(packageFile)) out.push([spelling, packageFile]);
|
||||
out.push([spelling, edge.targetFile]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Unaliased dotted imports bind their root package object, including namespace packages
|
||||
* without an __init__.py. Anchor identity on the resolved path, not spelling. */
|
||||
export function pythonNamespaceBindingIdentity(edge: {
|
||||
readonly localName: string;
|
||||
readonly importPath: string;
|
||||
readonly targetFile: string;
|
||||
readonly explicitAlias?: boolean;
|
||||
}): string | undefined {
|
||||
if (edge.explicitAlias === true) return edge.targetFile;
|
||||
const segments = edge.importPath.split('.');
|
||||
if (segments.length < 2 || segments[0] !== edge.localName) return edge.targetFile;
|
||||
const dirs = edge.targetFile.split('/').slice(0, -1);
|
||||
const count = edge.targetFile.endsWith('/__init__.py') ? segments.length : segments.length - 1;
|
||||
const offset = dirs.length - count;
|
||||
if (offset < 0) return undefined;
|
||||
return dirs.slice(0, offset + 1).join('/') + '/__init__.py';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export { pythonArityCompatibility } from './arity.js';
|
|||
export {
|
||||
isPythonImportedModule,
|
||||
pythonNamespaceReceiverPaths,
|
||||
pythonNamespaceBindingIdentity,
|
||||
resolvePythonImportTarget,
|
||||
type PythonResolveContext,
|
||||
} from './import-target.js';
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export function interpretPythonImport(captures: CaptureMatch): ParsedImport | nu
|
|||
return {
|
||||
kind: 'namespace',
|
||||
localName: aliasCap.text,
|
||||
explicitAlias: true,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import type {
|
|||
} from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { indexOnlyElementType } from '../../type-extractors/shared.js';
|
||||
import { pythonProvider } from '../python.js';
|
||||
import { expandPythonWildcardNames, pythonModuleExports } from './expand-wildcards.js';
|
||||
import {
|
||||
isPythonImportedModule,
|
||||
pythonNamespaceReceiverPaths,
|
||||
pythonNamespaceBindingIdentity,
|
||||
pythonArityCompatibility,
|
||||
pythonMergeBindings,
|
||||
resolvePythonImportTarget,
|
||||
|
|
@ -77,6 +79,13 @@ const pythonScopeResolver: ScopeResolver = {
|
|||
// A free call naming a class constructs it: `Service(db).do_work()` (#2708).
|
||||
constructionSyntax: { bare: true },
|
||||
language: SupportedLanguages.Python,
|
||||
// Imported names on a package's __init__.py are addressable as module members.
|
||||
namespaceExportsIncludeImportedNames: true,
|
||||
importsBindAtLexicalScope: true,
|
||||
moduleExports: pythonModuleExports,
|
||||
filterWildcardNames: (scope, names, files) => expandPythonWildcardNames(scope, files, names),
|
||||
namespaceBindingIdentity: pythonNamespaceBindingIdentity,
|
||||
namespaceSkipsEnclosingClasses: true,
|
||||
ownedMembersBindAtModuleScope: false,
|
||||
suppressReceiverLookup: (typeRef) => typeRef.source === 'decorator-unknown',
|
||||
languageProvider: pythonProvider,
|
||||
|
|
|
|||
|
|
@ -452,6 +452,17 @@ export interface ScopeResolver {
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
) => readonly string[];
|
||||
|
||||
/** Exact local module bindings, excluding definitions owned by nested scopes. */
|
||||
readonly moduleExports?: (file: ParsedFile) => ReadonlyMap<string, SymbolDefinition>;
|
||||
|
||||
/** Filter local and transitive export names for wildcard imports. Opting in
|
||||
* applies the filter both to re-export closures and imported bindings. */
|
||||
readonly filterWildcardNames?: (
|
||||
targetModuleScope: ScopeId,
|
||||
availableNames: readonly string[],
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => readonly string[];
|
||||
|
||||
/**
|
||||
* Optional one-shot loader for cross-file import-resolution config
|
||||
* (e.g. tsconfig path aliases for TypeScript, go.mod paths for Go,
|
||||
|
|
@ -1395,10 +1406,27 @@ export interface ScopeResolver {
|
|||
readonly localName: string;
|
||||
readonly importPath: string;
|
||||
readonly targetFile: string;
|
||||
readonly explicitAlias?: boolean;
|
||||
},
|
||||
moduleFileExists: (filePath: string) => boolean,
|
||||
) => readonly (readonly [spelling: string, targetFile: string])[] | undefined;
|
||||
|
||||
/** Stable identity of the namespace object bound by an import. Equal identities
|
||||
* allow distinct receiver paths to remain visible across lexical scopes.
|
||||
* Supplying this hook also suppresses same-scope import names with conflicting
|
||||
* or unprovably equal identities; a lone namespace with undefined identity
|
||||
* remains eligible. Without the hook, same-scope multi-target behavior stays. */
|
||||
readonly namespaceBindingIdentity?: (edge: {
|
||||
readonly localName: string;
|
||||
readonly importPath: string;
|
||||
readonly targetFile: string;
|
||||
readonly explicitAlias?: boolean;
|
||||
}) => string | undefined;
|
||||
|
||||
/** Enclosing class bodies are not lexical environments for namespace lookup.
|
||||
* The reference's own class scope remains visible for class-body expressions. */
|
||||
readonly namespaceSkipsEnclosingClasses?: boolean;
|
||||
|
||||
/**
|
||||
* Optional language-specific member-lattice lookup. Runs for a resolved
|
||||
* simple receiver type before the generic flattened-MRO walk. Languages
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ interface ResolveCompoundReceiverOptions {
|
|||
/** A namespace member may be a name the target module imported and
|
||||
* publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */
|
||||
readonly namespaceExportsIncludeImportedNames?: boolean;
|
||||
readonly namespaceSkipsEnclosingClasses?: boolean;
|
||||
/** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`,
|
||||
* `mod.Outer.Inner`) through the language's namespace chain walk
|
||||
* (`ScopeResolver.resolveNamespaceChains`). Seeds the dotted-chain walk
|
||||
|
|
@ -254,7 +255,15 @@ function resolveConstructionExpressionClass(
|
|||
// A verified namespace is authoritative: do not fall through to the
|
||||
// workspace-wide simple-name heuristics on either a miss or ambiguity.
|
||||
if (namespaceFiles.length > 0) {
|
||||
if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined;
|
||||
if (
|
||||
isNamespaceNameShadowed(
|
||||
namespaceName,
|
||||
inScope,
|
||||
scopes,
|
||||
options.namespaceSkipsEnclosingClasses,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const namespaceMatches = namespaceFiles
|
||||
.map((targetFile) =>
|
||||
options.namespaceExportsIncludeImportedNames === true
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ import {
|
|||
isOwnerNameShadowedBySomethingElse,
|
||||
lookupBindingsAt,
|
||||
} from '../scope/walkers.js';
|
||||
import {
|
||||
collectNamespaceTargets,
|
||||
type NamespaceTargetOptions,
|
||||
} from '../scope/namespace-targets.js';
|
||||
import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import { CALL_TARGET_TYPES } from '../../model/symbol-table.js';
|
||||
|
|
@ -151,6 +155,7 @@ export function resolveValueRefTarget(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
model: SemanticModel,
|
||||
publishesImportedNames: boolean,
|
||||
namespaceOptions?: NamespaceTargetOptions,
|
||||
): SymbolDefinition | undefined {
|
||||
const receiverName = site.explicitReceiver?.name;
|
||||
if (receiverName === undefined) {
|
||||
|
|
@ -178,6 +183,7 @@ export function resolveValueRefTarget(
|
|||
receiverName,
|
||||
scopes,
|
||||
publishesImportedNames,
|
||||
namespaceOptions,
|
||||
);
|
||||
if (viaNamespace !== undefined) {
|
||||
// `'owned'` is NOT "no answer" — it is "this receiver is a namespace handle
|
||||
|
|
@ -271,17 +277,25 @@ function findNamespaceValueRefTarget(
|
|||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
publishesImportedNames: boolean,
|
||||
namespaceOptions?: NamespaceTargetOptions,
|
||||
): SymbolDefinition | 'owned' | undefined {
|
||||
const moduleScopeId = scopes.moduleScopes.get(filePath);
|
||||
if (moduleScopeId === undefined) return undefined;
|
||||
const targetFiles: string[] = [];
|
||||
for (const edge of scopes.imports.get(moduleScopeId) ?? []) {
|
||||
if (edge.kind !== 'namespace' || edge.localName !== receiverName) continue;
|
||||
if (edge.targetFile === null) continue;
|
||||
if (!targetFiles.includes(edge.targetFile)) targetFiles.push(edge.targetFile);
|
||||
}
|
||||
const targetFiles =
|
||||
collectNamespaceTargets({ moduleScope: moduleScopeId }, scopes, {
|
||||
...namespaceOptions,
|
||||
inScope: site.inScope,
|
||||
}).get(receiverName) ?? [];
|
||||
if (targetFiles.length === 0) return undefined;
|
||||
if (isNamespaceNameShadowed(receiverName, site.inScope, scopes)) return undefined;
|
||||
if (
|
||||
isNamespaceNameShadowed(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
scopes,
|
||||
namespaceOptions?.skipEnclosingClasses,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
|
||||
/** The unique callable `select` finds across every target file, or nothing. */
|
||||
const uniqueMember = (
|
||||
|
|
@ -320,12 +334,11 @@ function findNamespaceValueRefTarget(
|
|||
// imported names for a name the file declares. Same rule here, so `x.f` and
|
||||
// `x.f()` cannot disagree about which module owns the name.
|
||||
//
|
||||
// Not reachable through valid Zig today — a container cannot declare a name
|
||||
// twice, so one target file cannot hold both spellings, and Zig is the only
|
||||
// provider that sets `namespaceExportsIncludeImportedNames`. It becomes
|
||||
// reachable the moment a second provider opts in, or a receiver binds more
|
||||
// than one target file; the guard is one `some` and the alternative failure
|
||||
// is a confident edge into the wrong module.
|
||||
// Providers that set `namespaceExportsIncludeImportedNames` can expose local
|
||||
// and published bindings under one name; a receiver can also bind more than
|
||||
// one target file. In either case, a local declaration owns the name even
|
||||
// when it is not callable. The guard is one `some` and the alternative
|
||||
// failure is a confident edge into the wrong module.
|
||||
const declaredLocally = targetFiles.some((targetFile) => {
|
||||
const targetScopeId = scopes.moduleScopes.get(targetFile);
|
||||
return targetScopeId !== undefined && localRefs(targetScopeId).length > 0;
|
||||
|
|
@ -339,7 +352,11 @@ function findNamespaceValueRefTarget(
|
|||
// does for the CALL form.
|
||||
const published = uniqueMember((scope) =>
|
||||
lookupBindingsAt(scope, site.name, scopes).filter(
|
||||
(ref) => ref.origin === 'import' || ref.origin === 'namespace' || ref.origin === 'reexport',
|
||||
(ref) =>
|
||||
ref.origin === 'import' ||
|
||||
ref.origin === 'namespace' ||
|
||||
ref.origin === 'reexport' ||
|
||||
ref.origin === 'wildcard',
|
||||
),
|
||||
);
|
||||
return published === 'ambiguous' || published === undefined ? 'owned' : published;
|
||||
|
|
@ -359,6 +376,7 @@ export function emitPropertyDispatchCalls(
|
|||
* so the CALL and the REGISTRATION forms of `hub.fn` cannot disagree.
|
||||
*/
|
||||
publishesImportedNames = false,
|
||||
namespaceOptions?: NamespaceTargetOptions,
|
||||
): {
|
||||
usesEmitted: number;
|
||||
callsEmitted: number;
|
||||
|
|
@ -380,6 +398,7 @@ export function emitPropertyDispatchCalls(
|
|||
scopes,
|
||||
model,
|
||||
publishesImportedNames,
|
||||
namespaceOptions,
|
||||
);
|
||||
if (def === undefined) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'stripTypePreservingDecoration'
|
||||
| 'resolveQualifiedReceiverMember'
|
||||
| 'namespaceReceiverPaths'
|
||||
| 'namespaceBindingIdentity'
|
||||
| 'namespaceSkipsEnclosingClasses'
|
||||
| 'resolveReceiverMember'
|
||||
| 'suppressReceiverLookup'
|
||||
| 'resolveThisViaEnclosingClass'
|
||||
|
|
@ -197,6 +199,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'normalizeTypeArgument'
|
||||
| 'markConstructionSites'
|
||||
| 'namespaceExportsIncludeImportedNames'
|
||||
| 'importsBindAtLexicalScope'
|
||||
| 'resolveNamespaceChains'
|
||||
>;
|
||||
|
||||
|
|
@ -504,7 +507,8 @@ export function emitReceiverBoundCalls(
|
|||
const key = segments.slice(0, k).join('.');
|
||||
const files = namespaceTargets.get(key);
|
||||
if (files === undefined) continue;
|
||||
if (isNamespaceNameShadowed(key, inScope, scopes)) return undefined;
|
||||
if (isNamespaceNameShadowed(key, inScope, scopes, provider.namespaceSkipsEnclosingClasses))
|
||||
return undefined;
|
||||
cursor = { files };
|
||||
rest = segments.slice(k);
|
||||
break;
|
||||
|
|
@ -558,13 +562,18 @@ export function emitReceiverBoundCalls(
|
|||
: uniqueClassAcross(cursor.files, tail);
|
||||
}
|
||||
const files = namespaceTargets.get(head);
|
||||
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
|
||||
if (
|
||||
files === undefined ||
|
||||
isNamespaceNameShadowed(head, inScope, scopes, provider.namespaceSkipsEnclosingClasses)
|
||||
)
|
||||
return undefined;
|
||||
return uniqueClassAcross(files, tail);
|
||||
};
|
||||
const compoundOpts = {
|
||||
fieldFallback,
|
||||
elementTypeOf: provider.elementTypeOf,
|
||||
namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true,
|
||||
namespaceSkipsEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
|
||||
hoistTypeBindingsToModule,
|
||||
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
|
||||
constructionSyntax: provider.constructionSyntax,
|
||||
|
|
@ -1033,20 +1042,30 @@ export function emitReceiverBoundCalls(
|
|||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const namespaceTargets = collectNamespaceTargets(parsed, scopes, {
|
||||
receiverPaths: provider.namespaceReceiverPaths,
|
||||
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
|
||||
});
|
||||
const fileCompoundOpts = {
|
||||
...compoundOpts,
|
||||
namespaceTargets,
|
||||
...(walkChains
|
||||
? {
|
||||
resolveQualifiedClass: (qualifiedName: string, inScope: ScopeId) =>
|
||||
resolveNamespaceQualifiedClass(qualifiedName, inScope, namespaceTargets),
|
||||
}
|
||||
: {}),
|
||||
const namespaceContext = (inScope?: ScopeId) => {
|
||||
const namespaceTargets = collectNamespaceTargets(parsed, scopes, {
|
||||
receiverPaths: provider.namespaceReceiverPaths,
|
||||
bindingIdentity: provider.namespaceBindingIdentity,
|
||||
skipEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
|
||||
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
|
||||
inScope,
|
||||
});
|
||||
return {
|
||||
namespaceTargets,
|
||||
fileCompoundOpts: {
|
||||
...compoundOpts,
|
||||
namespaceTargets,
|
||||
...(walkChains
|
||||
? {
|
||||
resolveQualifiedClass: (qualifiedName: string, scopeId: ScopeId) =>
|
||||
resolveNamespaceQualifiedClass(qualifiedName, scopeId, namespaceTargets),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
const fileNamespaces = namespaceContext();
|
||||
const namespacesByScope = new Map<ScopeId, ReturnType<typeof namespaceContext>>();
|
||||
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
|
||||
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
|
||||
// capture is a no-op and emission stays byte-identical (R4).
|
||||
|
|
@ -1059,6 +1078,17 @@ export function emitReceiverBoundCalls(
|
|||
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
|
||||
if (site.explicitReceiver === undefined) continue;
|
||||
|
||||
let namespaces = fileNamespaces;
|
||||
if (provider.importsBindAtLexicalScope === true) {
|
||||
let scoped = namespacesByScope.get(site.inScope);
|
||||
if (scoped === undefined) {
|
||||
scoped = namespaceContext(site.inScope);
|
||||
namespacesByScope.set(site.inScope, scoped);
|
||||
}
|
||||
namespaces = scoped;
|
||||
}
|
||||
const { namespaceTargets, fileCompoundOpts } = namespaces;
|
||||
|
||||
const receiverName = site.explicitReceiver.name;
|
||||
const memberName = site.name;
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
|
|
@ -1544,7 +1574,7 @@ export function emitReceiverBoundCalls(
|
|||
}
|
||||
|
||||
// ── Case 1: namespace receiver ───────────────────────────────
|
||||
// `namespaceTargets` is collected per FILE, so a local declaration that
|
||||
// `namespaceTargets` contains imports, so a local declaration that
|
||||
// shadows the import must suppress it — `def f(pkg): pkg.db.query()`
|
||||
// calls a method on the PARAMETER, and resolving it through the import
|
||||
// emits a wrong edge, not a missing one. The compound-receiver
|
||||
|
|
@ -1559,7 +1589,12 @@ export function emitReceiverBoundCalls(
|
|||
const namespaceCandidates = namespaceTargets.get(receiverName);
|
||||
let targetFiles: readonly string[] | undefined =
|
||||
namespaceCandidates !== undefined &&
|
||||
!isNamespaceNameShadowed(receiverName, site.inScope, scopes)
|
||||
!isNamespaceNameShadowed(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
scopes,
|
||||
provider.namespaceSkipsEnclosingClasses,
|
||||
)
|
||||
? namespaceCandidates
|
||||
: undefined;
|
||||
// Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is
|
||||
|
|
|
|||
|
|
@ -819,7 +819,9 @@ export function runScopeResolution(
|
|||
};
|
||||
}
|
||||
}
|
||||
const filterWildcardNames = provider.filterWildcardNames;
|
||||
const finalized = finalizeScopeModel(parsedFiles, {
|
||||
moduleExports: provider.moduleExports,
|
||||
hooks: {
|
||||
ownedMembersBindAtModuleScope: provider.ownedMembersBindAtModuleScope,
|
||||
importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true,
|
||||
|
|
@ -832,6 +834,11 @@ export function runScopeResolution(
|
|||
provider.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false,
|
||||
expandsWildcardTo: (targetModuleScope) =>
|
||||
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
|
||||
filterWildcardNames:
|
||||
filterWildcardNames === undefined
|
||||
? undefined
|
||||
: (targetModuleScope, names) =>
|
||||
filterWildcardNames(targetModuleScope, names, parsedFiles),
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
provider.mergeBindings(existing, incoming, scopeId),
|
||||
wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true,
|
||||
|
|
@ -1412,6 +1419,12 @@ export function runScopeResolution(
|
|||
// member (Case 1). Without it a hub module's re-exported callable
|
||||
// resolves when CALLED and declines when REGISTERED.
|
||||
provider.namespaceExportsIncludeImportedNames === true,
|
||||
{
|
||||
receiverPaths: provider.namespaceReceiverPaths,
|
||||
bindingIdentity: provider.namespaceBindingIdentity,
|
||||
skipEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
|
||||
moduleFileExists: (filePath) => indexes.moduleScopes.get(filePath) !== undefined,
|
||||
},
|
||||
);
|
||||
if (propertyDispatch.skippedKeys > 0) {
|
||||
// Never drop dispatch coverage silently: a hook table larger than the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Build a per-file `localName → targetFilePath` map over the file's
|
||||
* module-scope namespace-kind import edges.
|
||||
* Build a `localName → targetFilePath` map over visible namespace imports.
|
||||
* Defaults to module scope; callers can opt into lexical scope traversal.
|
||||
*
|
||||
* Namespace imports (`import X`, `import X as Y`) bind a name that can
|
||||
* appear as a receiver in member calls (`X.foo()`, `Y.foo()`). Named
|
||||
|
|
@ -29,29 +29,24 @@
|
|||
* `import Foo.Bar`, where the same pair means the opposite thing — see the
|
||||
* hook's contract note.
|
||||
*
|
||||
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
|
||||
* documents that function-local and class-body imports bind to the
|
||||
* inner scope, which would make a module-only read incomplete. In
|
||||
* practice `finalize-algorithm` places ALL of a file's ImportEdges
|
||||
* onto `indexes.imports[moduleScope]` regardless of where the
|
||||
* `import` statement appears — the integration fixtures
|
||||
* `python-function-local-namespace-import` and
|
||||
* `python-class-body-namespace-import` both emit correct CALLS edges
|
||||
* with reason "namespace-receiver", demonstrating that the module-
|
||||
* scope read is sufficient today. If finalize routing ever changes to
|
||||
* honor the hook's per-scope contract, this function must walk the
|
||||
* reference-site scope chain (mirror `findExportedDefByName`).
|
||||
* Lexically scoped imports are collected from the reference's scope outward.
|
||||
* The nearest scope owns each import name. Providers may retain outer receiver
|
||||
* paths that refer to that same namespace object; sibling scopes are invisible.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
|
||||
export interface NamespaceTargetOptions {
|
||||
/** Reference scope for providers that bind imports lexically. */
|
||||
readonly inScope?: ScopeId;
|
||||
/** `ScopeResolver.namespaceReceiverPaths` for the file's language. Absent
|
||||
* (or returning `undefined` per edge) keeps the local-name-only default:
|
||||
* the extra spellings are opt-in, never inferred from the edge shape. */
|
||||
readonly receiverPaths?: ScopeResolver['namespaceReceiverPaths'];
|
||||
readonly bindingIdentity?: ScopeResolver['namespaceBindingIdentity'];
|
||||
readonly skipEnclosingClasses?: boolean;
|
||||
/** Whether a path is a module the workspace parsed. Lets a provider propose
|
||||
* a prefix file and have it dropped when absent, instead of minting a key
|
||||
* to a file that does not exist. Defaults to "nothing exists". */
|
||||
|
|
@ -59,13 +54,25 @@ export interface NamespaceTargetOptions {
|
|||
}
|
||||
|
||||
export function collectNamespaceTargets(
|
||||
parsed: ParsedFile,
|
||||
parsed: Pick<ParsedFile, 'moduleScope'>,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NamespaceTargetOptions,
|
||||
): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
const moduleEdges = scopes.imports.get(parsed.moduleScope);
|
||||
if (moduleEdges === undefined) return out;
|
||||
const scopeIds: ScopeId[] = [];
|
||||
let scopeId: ScopeId | null = options?.inScope ?? parsed.moduleScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (scopeId !== null && !visited.has(scopeId)) {
|
||||
visited.add(scopeId);
|
||||
const scope = scopeId === parsed.moduleScope ? undefined : scopes.scopeTree.getScope(scopeId);
|
||||
if (
|
||||
!(options?.skipEnclosingClasses && scope?.kind === 'Class' && scopeId !== options.inScope)
|
||||
) {
|
||||
scopeIds.push(scopeId);
|
||||
}
|
||||
if (scopeId === parsed.moduleScope) break;
|
||||
scopeId = scope?.parent ?? null;
|
||||
}
|
||||
|
||||
const addTarget = (key: string, targetFile: string): void => {
|
||||
let targets = out.get(key);
|
||||
|
|
@ -78,25 +85,48 @@ export function collectNamespaceTargets(
|
|||
|
||||
const moduleFileExists = options?.moduleFileExists ?? ((): boolean => false);
|
||||
|
||||
for (const edge of moduleEdges) {
|
||||
if (edge.targetFile === null || edge.kind !== 'namespace') continue;
|
||||
const claimedNames = new Map<string, string | undefined>();
|
||||
for (const id of scopeIds) {
|
||||
const edges = scopes.imports.get(id) ?? [];
|
||||
const namesHere = new Map<string, string | undefined>();
|
||||
const conflictingNames = new Set<string>();
|
||||
const candidates = edges.map((edge) => {
|
||||
const namespace =
|
||||
edge.targetFile !== null && edge.kind === 'namespace' && edge.linkStatus !== 'unresolved'
|
||||
? {
|
||||
localName: edge.localName,
|
||||
importPath: edge.targetExportedName,
|
||||
targetFile: edge.targetFile,
|
||||
explicitAlias: edge.explicitAlias,
|
||||
}
|
||||
: undefined;
|
||||
const identity = namespace === undefined ? undefined : options?.bindingIdentity?.(namespace);
|
||||
if (!namesHere.has(edge.localName)) namesHere.set(edge.localName, identity);
|
||||
else if (identity === undefined || namesHere.get(edge.localName) !== identity) {
|
||||
namesHere.set(edge.localName, undefined);
|
||||
conflictingNames.add(edge.localName);
|
||||
}
|
||||
return { edge, namespace, identity };
|
||||
});
|
||||
// Resolve ownership before emitting: import order alone cannot distinguish
|
||||
// rebinding from conditional alternatives. Providers without an identity
|
||||
// hook retain their existing multi-target behavior within a scope.
|
||||
for (const { edge, namespace, identity } of candidates) {
|
||||
if (options?.bindingIdentity !== undefined && conflictingNames.has(edge.localName)) continue;
|
||||
if (
|
||||
claimedNames.has(edge.localName) &&
|
||||
(identity === undefined || claimedNames.get(edge.localName) !== identity)
|
||||
)
|
||||
continue;
|
||||
if (namespace === undefined) continue;
|
||||
|
||||
const spellings = options?.receiverPaths?.(
|
||||
{
|
||||
localName: edge.localName,
|
||||
importPath: edge.targetExportedName,
|
||||
targetFile: edge.targetFile,
|
||||
},
|
||||
moduleFileExists,
|
||||
);
|
||||
|
||||
// A provider that declines this edge — or has no hook — gets the default:
|
||||
// the bound name alone, pointing at this edge's own target.
|
||||
if (spellings === undefined) {
|
||||
addTarget(edge.localName, edge.targetFile);
|
||||
continue;
|
||||
const spellings = options?.receiverPaths?.(namespace, moduleFileExists);
|
||||
if (spellings === undefined) addTarget(edge.localName, namespace.targetFile);
|
||||
else for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile);
|
||||
}
|
||||
for (const [name, identity] of namesHere) {
|
||||
if (!claimedNames.has(name)) claimedNames.set(name, identity);
|
||||
}
|
||||
for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@ export function isNamespaceNameShadowed(
|
|||
namespaceName: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
skipEnclosingClasses = false,
|
||||
): boolean {
|
||||
const firstDot = namespaceName.indexOf('.');
|
||||
const rootName = firstDot === -1 ? namespaceName : namespaceName.slice(0, firstDot);
|
||||
|
|
@ -326,6 +327,10 @@ export function isNamespaceNameShadowed(
|
|||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return true;
|
||||
if (skipEnclosingClasses && scope.kind === 'Class' && currentId !== inScope) {
|
||||
currentId = scope.parent;
|
||||
continue;
|
||||
}
|
||||
// Stop AT the module scope without inspecting it. In languages where a
|
||||
// namespace import IS a variable declaration — CommonJS
|
||||
// `const svc = require('./svc')` — the import puts its own name into the
|
||||
|
|
@ -2294,7 +2299,12 @@ export function findExportedDefIncludingImportedNames(
|
|||
if (moduleScope === undefined) return undefined;
|
||||
let picked: SymbolDefinition | undefined;
|
||||
for (const ref of lookupBindingsAt(moduleScope.id, memberName, scopes)) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'namespace' && ref.origin !== 'reexport')
|
||||
if (
|
||||
ref.origin !== 'import' &&
|
||||
ref.origin !== 'namespace' &&
|
||||
ref.origin !== 'reexport' &&
|
||||
ref.origin !== 'wildcard'
|
||||
)
|
||||
continue;
|
||||
if (picked === undefined) {
|
||||
picked = ref.def;
|
||||
|
|
|
|||
|
|
@ -835,7 +835,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// v130 (#3502): Python globals use exact function/class block ownership and
|
||||
// Ruby ordinary-method binding uses AST markers. Warm v129 ParsedFiles retain
|
||||
// incorrect declaration scopes and must be re-extracted.
|
||||
const SCHEMA_BUMP = 130;
|
||||
// v131 (#3504): Python namespace imports retain explicit alias syntax. Warm
|
||||
// v130 ParsedFiles lack this fact and can bind a root-spelled alias to the
|
||||
// package root instead of the imported module.
|
||||
const SCHEMA_BUMP = 131;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
1
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/__init__.py
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/__init__.py
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
4
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/combo.py
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/combo.py
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .services import bp as backplane
|
||||
|
||||
def combo_caller():
|
||||
return backplane.pf()
|
||||
4
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/reex.py
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/reex.py
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .services.bp import pf
|
||||
|
||||
def reexport_pkg_caller():
|
||||
return pf()
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from .gp import (
|
||||
pf,
|
||||
)
|
||||
|
||||
__all__ = ["pf"]
|
||||
2
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/gp.py
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/python-aliased-package-reexport/app/services/bp/gp.py
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
def pf():
|
||||
return 1
|
||||
|
|
@ -19,6 +19,30 @@
|
|||
"captureGroups": 13,
|
||||
"digest": "cec30bb7577edfb751bcd4f5026a0c9d77ed235071421a2a46f87580c7bcdf47"
|
||||
},
|
||||
"python-aliased-package-reexport/app/__init__.py": {
|
||||
"captureGroups": 1,
|
||||
"digest": "532aee1a4b67f99b8d843a235afd851cdab275de0b6cb02dbdcf6f7670f264aa"
|
||||
},
|
||||
"python-aliased-package-reexport/app/combo.py": {
|
||||
"captureGroups": 5,
|
||||
"digest": "1c33bce592afba5a30b510ca69204b62e03b44c66716cae5cde318b348b909fe"
|
||||
},
|
||||
"python-aliased-package-reexport/app/reex.py": {
|
||||
"captureGroups": 5,
|
||||
"digest": "f55df8dd034b73c8d2c45a3eb97fb4244ec37bf6be28a78586627ba76ec419e6"
|
||||
},
|
||||
"python-aliased-package-reexport/app/services/__init__.py": {
|
||||
"captureGroups": 1,
|
||||
"digest": "532aee1a4b67f99b8d843a235afd851cdab275de0b6cb02dbdcf6f7670f264aa"
|
||||
},
|
||||
"python-aliased-package-reexport/app/services/bp/__init__.py": {
|
||||
"captureGroups": 3,
|
||||
"digest": "7cd93974ed3d3d31451ce9e5e9bb0dd68f6a015f24e865658045d50d0c031fc7"
|
||||
},
|
||||
"python-aliased-package-reexport/app/services/bp/gp.py": {
|
||||
"captureGroups": 3,
|
||||
"digest": "89564bb524c12295365d65a304418c519b4910f29c53cfe2b755bc55a5d25474"
|
||||
},
|
||||
"python-ambiguous/models/__init__.py": {
|
||||
"captureGroups": 0,
|
||||
"digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getRelationships,
|
||||
runPipelineFromRepo,
|
||||
writeFixtureRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('Python wildcard definitions and lexical namespace visibility', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-python-namespace-semantics-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'definitions.py':
|
||||
'class Worker:\n def run(self):\n return 1\ndef run():\n return 2\n',
|
||||
'values.py': 'run = 42\nclass Worker:\n def run(self):\n return 1\n',
|
||||
'definitions_star.py': 'from definitions import *\n',
|
||||
'values_star.py': 'from values import *\n',
|
||||
'origin.py':
|
||||
'def helper():\n return 1\ndef _hidden():\n return 2\nclass Maker:\n def work(self):\n return 3\n',
|
||||
'pkg/__init__.py': 'from origin import helper as exported, Maker, _hidden\n',
|
||||
'pkg/a.py': 'def run():\n return 3\n',
|
||||
'pkg/b.py': 'def other():\n return 4\n',
|
||||
'pkg/nested/__init__.py': 'def other():\n return 11\n',
|
||||
'namespace_pkg/a.py': 'def run():\n return 5\n',
|
||||
'namespace_pkg/b.py': 'def other():\n return 6\n',
|
||||
'star.py': 'from pkg import *\n',
|
||||
'multi_star.py': 'from star import *\n',
|
||||
'exports.py': '__all__ = ["exported"]\n',
|
||||
'assigned_all.py': 'from pkg import exported\n__all__ = ["exported"]\n',
|
||||
'imported_all.py': 'from pkg import exported\nfrom exports import __all__\n',
|
||||
'local_all.py':
|
||||
'from pkg import exported\ndef configure():\n from exports import __all__\n',
|
||||
'assigned_star.py': 'from assigned_all import *\n',
|
||||
'imported_star.py': 'from imported_all import *\n',
|
||||
'blocked_multi.py': 'from assigned_star import *\n',
|
||||
'local_star.py': 'from local_all import *\n',
|
||||
'cycle_a.py': 'from cycle_b import *\ndef cyclic():\n return 7\n',
|
||||
'cycle_b.py': 'from cycle_a import *\n',
|
||||
'module_target.py': 'def helper():\n return 8\n',
|
||||
'class_target.py': 'def helper():\n return 9\n',
|
||||
'closure_target.py': 'def helper():\n return 10\n',
|
||||
'decoy.py':
|
||||
'def run():\n return 0\ndef exported():\n return 0\ndef _hidden():\n return 0\n',
|
||||
'module_rebinding.py': `import module_target as mod
|
||||
from values import run as mod
|
||||
def module_rebound():
|
||||
return mod.helper()
|
||||
`,
|
||||
'module_rebinding_reverse.py': `from values import run as mod
|
||||
import module_target as mod
|
||||
def module_rebound_reverse():
|
||||
return mod.helper()
|
||||
`,
|
||||
'app.py': `import definitions_star
|
||||
import values_star
|
||||
import star
|
||||
import multi_star
|
||||
import assigned_star
|
||||
import imported_star
|
||||
import blocked_multi
|
||||
import local_star
|
||||
import cycle_b
|
||||
import pkg.a
|
||||
import namespace_pkg.a
|
||||
import module_target as mod
|
||||
|
||||
def module_definition():
|
||||
return definitions_star.run()
|
||||
def module_value():
|
||||
return values_star.run()
|
||||
def imported_export():
|
||||
return star.exported()
|
||||
def transitive_export():
|
||||
return multi_star.exported()
|
||||
def imported_class():
|
||||
return multi_star.Maker().work()
|
||||
def private_export():
|
||||
return multi_star._hidden()
|
||||
def assigned_exports():
|
||||
return assigned_star.exported()
|
||||
def imported_exports():
|
||||
return imported_star.exported()
|
||||
def transitive_blocked_exports():
|
||||
return blocked_multi.exported()
|
||||
def local_exports():
|
||||
return local_star.exported()
|
||||
def cyclic_export():
|
||||
return cycle_b.cyclic()
|
||||
def sibling_package():
|
||||
import pkg.b
|
||||
return pkg.a.run()
|
||||
def sibling_namespace_package():
|
||||
import namespace_pkg.b
|
||||
return namespace_pkg.a.run()
|
||||
def named_shadow():
|
||||
from origin import Maker as pkg
|
||||
return pkg.a.run()
|
||||
def unresolved_shadow():
|
||||
import unavailable as pkg
|
||||
return pkg.a.run()
|
||||
def different_package():
|
||||
import namespace_pkg.b as pkg
|
||||
return pkg.a.run()
|
||||
def root_named_module_alias():
|
||||
import pkg.b as pkg
|
||||
return pkg.other()
|
||||
def root_named_package_alias():
|
||||
import pkg.nested as pkg
|
||||
return pkg.other()
|
||||
def root_named_module_shadow():
|
||||
import pkg.b as pkg
|
||||
return pkg.a.run()
|
||||
def root_named_package_shadow():
|
||||
import pkg.nested as pkg
|
||||
return pkg.a.run()
|
||||
def root_named_module_path():
|
||||
import pkg.b as pkg
|
||||
return pkg.b.other()
|
||||
def root_named_package_path():
|
||||
import pkg.nested as pkg
|
||||
return pkg.nested.other()
|
||||
|
||||
class ClassOnly:
|
||||
import class_target as class_mod
|
||||
value = class_mod.helper()
|
||||
def class_only(self):
|
||||
return class_mod.helper()
|
||||
|
||||
class WithModule:
|
||||
import class_target as mod
|
||||
value = mod.helper()
|
||||
def module_visible(self):
|
||||
return mod.helper()
|
||||
def parameter_shadow(self, mod):
|
||||
return mod.helper()
|
||||
|
||||
def namespace_rebound():
|
||||
import module_target as mod
|
||||
import class_target as mod
|
||||
return mod.helper()
|
||||
def named_rebound():
|
||||
import module_target as mod
|
||||
from values import run as mod
|
||||
return mod.helper()
|
||||
def named_rebound_reverse():
|
||||
from values import run as mod
|
||||
import module_target as mod
|
||||
return mod.helper()
|
||||
def conditional_rebound(flag):
|
||||
if flag:
|
||||
import module_target as mod
|
||||
else:
|
||||
from values import run as mod
|
||||
return mod.helper()
|
||||
def conditional_namespaces(flag):
|
||||
if flag:
|
||||
import module_target as mod
|
||||
else:
|
||||
import class_target as mod
|
||||
return mod.helper()
|
||||
def compatible_imports():
|
||||
import pkg.a
|
||||
import pkg.b
|
||||
import pkg.a
|
||||
pkg.a.run()
|
||||
return pkg.b.other()
|
||||
def duplicate_imports():
|
||||
import module_target as mod
|
||||
import module_target as mod
|
||||
return mod.helper()
|
||||
|
||||
def enclosing():
|
||||
import closure_target as enclosed_mod
|
||||
class Nested:
|
||||
import class_target as enclosed_mod
|
||||
def closure_visible(self):
|
||||
return enclosed_mod.helper()
|
||||
return Nested
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const callsFrom = (name: string) =>
|
||||
getRelationships(result, 'CALLS').filter((edge) => edge.source === name);
|
||||
|
||||
it('keeps the exact module definition when a class method has the same name', () => {
|
||||
expect(callsFrom('module_definition').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:definitions.py:run',
|
||||
]);
|
||||
expect(callsFrom('module_value').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Variable:values.py:run',
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes imported aliases through one or more wildcard barrels', () => {
|
||||
for (const name of ['imported_export', 'transitive_export', 'local_exports']) {
|
||||
expect(callsFrom(name).map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:origin.py:helper',
|
||||
]);
|
||||
}
|
||||
expect(
|
||||
callsFrom('imported_class').map(
|
||||
(edge) => `${edge.targetLabel}:${edge.targetFilePath}:${edge.target}`,
|
||||
),
|
||||
).toEqual(['Class:origin.py:Maker', 'Method:origin.py:work']);
|
||||
});
|
||||
|
||||
it('filters private names and declines explicit module-level __all__ through every hop', () => {
|
||||
for (const name of [
|
||||
'private_export',
|
||||
'assigned_exports',
|
||||
'imported_exports',
|
||||
'transitive_blocked_exports',
|
||||
]) {
|
||||
expect(callsFrom(name), name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('propagates wildcard exports through a bounded cycle', () => {
|
||||
expect(callsFrom('cyclic_export').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:cycle_a.py:cyclic',
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains compatible outer submodule paths for regular and namespace packages', () => {
|
||||
expect(callsFrom('sibling_package').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:pkg/a.py:run',
|
||||
]);
|
||||
expect(callsFrom('sibling_namespace_package').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:namespace_pkg/a.py:run',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still suppresses outer paths under named, unresolved, and different namespace imports', () => {
|
||||
for (const name of ['named_shadow', 'unresolved_shadow', 'different_package']) {
|
||||
expect(callsFrom(name), name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds root-spelled aliases to the imported module or package', () => {
|
||||
expect(callsFrom('root_named_module_alias').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:pkg/b.py:other',
|
||||
]);
|
||||
expect(callsFrom('root_named_package_alias').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:pkg/nested/__init__.py:other',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not expose original or outer package paths through root-spelled aliases', () => {
|
||||
for (const name of [
|
||||
'root_named_module_shadow',
|
||||
'root_named_package_shadow',
|
||||
'root_named_module_path',
|
||||
'root_named_package_path',
|
||||
]) {
|
||||
expect(callsFrom(name), name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('suppresses conflicting imports in module and function scopes regardless of order', () => {
|
||||
for (const name of [
|
||||
'module_rebound',
|
||||
'module_rebound_reverse',
|
||||
'namespace_rebound',
|
||||
'named_rebound',
|
||||
'named_rebound_reverse',
|
||||
'conditional_rebound',
|
||||
'conditional_namespaces',
|
||||
]) {
|
||||
expect(callsFrom(name), name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves compatible dotted imports and repeated identical aliases in one scope', () => {
|
||||
expect(
|
||||
callsFrom('compatible_imports')
|
||||
.map((edge) => edge.rel.targetId)
|
||||
.sort(),
|
||||
).toEqual(['Function:pkg/a.py:run', 'Function:pkg/b.py:other']);
|
||||
expect(callsFrom('duplicate_imports').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:module_target.py:helper',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips class imports when looking outward from methods', () => {
|
||||
expect(callsFrom('class_only')).toEqual([]);
|
||||
expect(callsFrom('module_visible').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:module_target.py:helper',
|
||||
]);
|
||||
expect(callsFrom('closure_visible').map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:closure_target.py:helper',
|
||||
]);
|
||||
expect(callsFrom('parameter_shadow')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps class imports visible within their own class bodies', () => {
|
||||
const classCalls = getRelationships(result, 'CALLS').filter(
|
||||
(edge) => edge.rel.targetId === 'Function:class_target.py:helper',
|
||||
);
|
||||
expect(classCalls).toHaveLength(2);
|
||||
expect(classCalls.map((edge) => edge.source).sort()).toEqual(['ClassOnly', 'WithModule']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1013,6 +1013,281 @@ describe('Python re-export chain resolution', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Python aliased package re-export resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'python-aliased-package-reexport'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('resolves both aliased package member calls and direct re-export calls', () => {
|
||||
const calls = getRelationships(result, 'CALLS')
|
||||
.filter((call) => ['combo_caller', 'reexport_pkg_caller'].includes(call.source))
|
||||
.map((call) => [call.source, call.target, call.targetFilePath])
|
||||
.sort();
|
||||
expect(calls).toEqual([
|
||||
['combo_caller', 'pf', 'app/services/bp/gp.py'],
|
||||
['reexport_pkg_caller', 'pf', 'app/services/bp/gp.py'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dotted imports: package prefixes must not borrow the leaf module's bindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python dotted import package ownership', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-package-ownership-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'app.py': `import pkg.db
|
||||
import hidden_pkg.leaf
|
||||
import deep.nested
|
||||
|
||||
def construct():
|
||||
return pkg.User().save()
|
||||
|
||||
def hidden_on_package():
|
||||
return hidden_pkg.hidden()
|
||||
|
||||
def visible_on_leaf():
|
||||
return hidden_pkg.leaf.hidden()
|
||||
|
||||
def root_member():
|
||||
return deep.marker()
|
||||
|
||||
def nested_member():
|
||||
return deep.nested.marker()
|
||||
|
||||
def renamed_member():
|
||||
return pkg.renamed()
|
||||
`,
|
||||
'pkg/__init__.py': 'from .db import User\nfrom .bridge import renamed\n__all__ = []\n',
|
||||
'pkg/db.py': 'class User:\n def save(self):\n return 1\n',
|
||||
'pkg/bridge.py': 'from .service import original as renamed\n',
|
||||
'pkg/service.py': 'def original():\n return 2\n',
|
||||
'hidden_pkg/__init__.py': '',
|
||||
'hidden_pkg/leaf.py': 'from other import hidden\n',
|
||||
'other.py': 'def hidden():\n return 3\n',
|
||||
'deep/__init__.py': 'from .root_service import marker\n',
|
||||
'deep/root_service.py': 'def marker():\n return 4\n',
|
||||
'deep/nested/__init__.py': 'from .service import marker\n',
|
||||
'deep/nested/service.py': 'def marker():\n return 5\n',
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps the method call on a class re-exported from the imported leaf', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((call) => call.source === 'construct');
|
||||
expect(calls.map((call) => [call.target, call.targetFilePath]).sort()).toEqual([
|
||||
['User', 'pkg/db.py'],
|
||||
['save', 'pkg/db.py'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not publish leaf-only imports on the package', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.filter((call) => call.source === 'hidden_on_package')).toEqual([]);
|
||||
expect(
|
||||
calls
|
||||
.filter((call) => call.source === 'visible_on_leaf')
|
||||
.map((call) => [call.target, call.targetFilePath]),
|
||||
).toEqual([['hidden', 'other.py']]);
|
||||
});
|
||||
|
||||
it('keeps root and nested package re-exports in their own namespaces', () => {
|
||||
const calls = getRelationships(result, 'CALLS')
|
||||
.filter((call) => ['root_member', 'nested_member'].includes(call.source))
|
||||
.map((call) => [call.source, call.target, call.targetFilePath])
|
||||
.sort();
|
||||
expect(calls).toEqual([
|
||||
['nested_member', 'marker', 'deep/nested/service.py'],
|
||||
['root_member', 'marker', 'deep/root_service.py'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('follows renamed re-export chains for explicit access even outside __all__', () => {
|
||||
const calls = getRelationships(result, 'CALLS')
|
||||
.filter((call) => call.source === 'renamed_member')
|
||||
.map((call) => [call.target, call.targetFilePath]);
|
||||
expect(calls).toEqual([['original', 'pkg/service.py']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python namespace re-export visibility', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-reexport-visibility-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'pkg/__init__.py': '',
|
||||
'pkg/impl.py': `def run():
|
||||
return 1
|
||||
|
||||
class Service:
|
||||
def work(self):
|
||||
return 2
|
||||
|
||||
def _hidden():
|
||||
return 3
|
||||
`,
|
||||
'pkg/named.py': `from .impl import run as renamed, Service, _hidden
|
||||
__all__ = []
|
||||
`,
|
||||
'pkg/chain.py': 'from .named import renamed as again\n',
|
||||
'pkg/star.py': 'from .impl import *\n',
|
||||
'pkg/restricted_impl.py': '__all__ = []\ndef run():\n return 5\n',
|
||||
'pkg/restricted.py': 'from .restricted_impl import *\n',
|
||||
'pkg/private.py': `def setup():
|
||||
from .impl import run
|
||||
return run()
|
||||
|
||||
class Holder:
|
||||
from .impl import Service
|
||||
`,
|
||||
'pkg/local.py': `from .impl import run
|
||||
|
||||
def run():
|
||||
return 4
|
||||
`,
|
||||
'inner.py': `from pkg import impl as api
|
||||
|
||||
def inner_alias():
|
||||
from pkg import local as api
|
||||
return api.run()
|
||||
|
||||
def sibling():
|
||||
return api.run()
|
||||
|
||||
def local_only():
|
||||
from pkg import impl as scoped
|
||||
return scoped.run()
|
||||
|
||||
def absent_sibling():
|
||||
return scoped.run()
|
||||
|
||||
def outer():
|
||||
from pkg import local as scoped
|
||||
def closure():
|
||||
return scoped.run()
|
||||
return closure()
|
||||
`,
|
||||
'consumer.py': `from pkg import named as ns, chain, star, private, local, restricted
|
||||
|
||||
def alias():
|
||||
return ns.renamed()
|
||||
|
||||
def hidden():
|
||||
return ns._hidden()
|
||||
|
||||
def chained():
|
||||
return chain.again()
|
||||
|
||||
def wildcard():
|
||||
return star.run()
|
||||
|
||||
def wildcard_private():
|
||||
return star._hidden()
|
||||
|
||||
def wildcard_restricted():
|
||||
return restricted.run()
|
||||
|
||||
def wildcard_nested():
|
||||
return star.work()
|
||||
|
||||
def not_exported():
|
||||
return private.run()
|
||||
|
||||
def not_exported_class():
|
||||
return private.Service().work()
|
||||
|
||||
def receiver_shadow(ns):
|
||||
return ns.renamed()
|
||||
|
||||
def local_member():
|
||||
return local.run()
|
||||
|
||||
def compound():
|
||||
return ns.Service().work()
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves named, aliased and transitive members regardless of __all__', () => {
|
||||
const edges = getRelationships(result, 'CALLS')
|
||||
.filter((call) => ['alias', 'hidden', 'chained', 'compound'].includes(call.source))
|
||||
.map((call) => `${call.source}->${call.target}@${call.targetFilePath}`)
|
||||
.sort();
|
||||
expect(edges).toEqual([
|
||||
'alias->run@pkg/impl.py',
|
||||
'chained->run@pkg/impl.py',
|
||||
'compound->Service@pkg/impl.py',
|
||||
'compound->work@pkg/impl.py',
|
||||
'hidden->_hidden@pkg/impl.py',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes wildcard-imported names as module members', () => {
|
||||
const edges = getRelationships(result, 'CALLS')
|
||||
.filter((call) => call.source === 'wildcard')
|
||||
.map((call) => `${call.target}@${call.targetFilePath}`);
|
||||
expect(edges).toEqual(['run@pkg/impl.py']);
|
||||
expect(
|
||||
getRelationships(result, 'CALLS').filter((call) =>
|
||||
['wildcard_private', 'wildcard_restricted', 'wildcard_nested'].includes(call.source),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not publish function-local or class-body imports as module members', () => {
|
||||
const edges = getRelationships(result, 'CALLS').filter((call) =>
|
||||
['not_exported', 'not_exported_class'].includes(call.source),
|
||||
);
|
||||
expect(edges).toEqual([]);
|
||||
expect(
|
||||
getRelationships(result, 'CALLS')
|
||||
.filter((call) => call.source === 'setup')
|
||||
.map((call) => `${call.target}@${call.targetFilePath}`),
|
||||
).toEqual(['run@pkg/impl.py']);
|
||||
});
|
||||
|
||||
it('keeps namespace imports local to their function and visible to closures', () => {
|
||||
const edges = getRelationships(result, 'CALLS')
|
||||
.filter((call) => call.sourceFilePath === 'inner.py' && call.target === 'run')
|
||||
.map((call) => `${call.source}->${call.targetFilePath}`)
|
||||
.sort();
|
||||
expect(edges).toEqual([
|
||||
'closure->pkg/local.py',
|
||||
'inner_alias->pkg/local.py',
|
||||
'local_only->pkg/impl.py',
|
||||
'sibling->pkg/impl.py',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves local-member and receiver shadowing', () => {
|
||||
const edges = getRelationships(result, 'CALLS')
|
||||
.filter((call) => ['local_member', 'receiver_shadow'].includes(call.source))
|
||||
.map((call) => `${call.source}->${call.target}@${call.targetFilePath}`);
|
||||
expect(edges).toEqual(['local_member->run@pkg/local.py']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local shadow: same-file definition takes priority over imported name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -3849,12 +4124,8 @@ describe('Python class-body attribute does NOT leak into module export index', (
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Function-local import + cross-file return-type propagation
|
||||
// Codex round-2 flagged this as potentially broken, but empirically the
|
||||
// finalize-algorithm hoists the `from svc import get_user` binding to
|
||||
// the app.py module scope (observed via indexes.bindings dump), so
|
||||
// `propagateImportedReturnTypes`'s module-scope pass already handles
|
||||
// it. These assertions pin that working behavior as a regression
|
||||
// guard against any future change to binding-scope routing.
|
||||
// The import stays in its function scope; return-type propagation must still
|
||||
// connect a call on the imported function's result to its defining method.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python function-local import feeds chained receiver-bound call', () => {
|
||||
|
|
@ -3885,13 +4156,8 @@ describe('Python function-local import feeds chained receiver-bound call', () =>
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Function-local namespace import: `def f(): import svc as s; s.call()`
|
||||
// Codex round-3 flagged this pattern as potentially broken because
|
||||
// collectNamespaceTargets reads only module-scope imports. Empirically
|
||||
// the edge IS emitted (finalize hoists ImportEdges onto the module
|
||||
// scope), so these assertions pin the working behavior. If finalize
|
||||
// routing ever changes to match pythonImportOwningScope's per-scope
|
||||
// contract, this block will flip red and signal the need to make
|
||||
// collectNamespaceTargets scope-chain-aware.
|
||||
// Namespace targets follow the lexical scope chain, so the local import is
|
||||
// visible inside its function without becoming a module export.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python function-local namespace import feeds receiver-bound call', () => {
|
||||
|
|
@ -3921,13 +4187,10 @@ describe('Python function-local namespace import feeds receiver-bound call', ()
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Class-body namespace import: `class A: import mod; def use(): mod.helper()`
|
||||
// Same theoretical concern as the function-local case above, same
|
||||
// empirical outcome — finalize hoists the ImportEdge to the module
|
||||
// scope so the namespace-receiver path finds it from inside A.use.
|
||||
// These assertions pin that working behavior.
|
||||
// Class namespaces are not enclosing lexical environments for method bodies.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python class-body namespace import feeds method receiver-bound call', () => {
|
||||
describe('Python class-body namespace import is invisible to methods', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -3937,11 +4200,10 @@ describe('Python class-body namespace import feeds method receiver-bound call',
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('emits CALLS edge A.use -> mod.helper via class-body `import mod`', () => {
|
||||
it('does not emit A.use -> mod.helper for a class-body-only import', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const callEdge = calls.find((c) => c.source === 'use' && c.target === 'helper');
|
||||
expect(callEdge).toBeDefined();
|
||||
expect(callEdge!.rel.targetId).toContain('mod.py:helper');
|
||||
expect(callEdge).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -303,8 +303,8 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// Moved 125 -> 126 for #3446: SDK positional tool definitions and attribution.
|
||||
// Moved 126 -> 127 for #3450: reject destructured SDK registration-method writes.
|
||||
// Moved 127 -> 128 for #3450: recognize SDK namespace imports.
|
||||
it('pins SCHEMA_BUMP to 130 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(130);
|
||||
it('pins SCHEMA_BUMP to 131 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3504)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(131);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
|
|
@ -314,7 +314,7 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
|
||||
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
|
||||
123, 124, 125, 126, 127,
|
||||
123, 124, 125, 126, 127, 128, 129, 130,
|
||||
]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import { collectNamespaceTargets } from '../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js';
|
||||
import { pythonNamespaceReceiverPaths } from '../../../src/core/ingestion/languages/python/import-target.js';
|
||||
import {
|
||||
pythonNamespaceReceiverPaths,
|
||||
pythonNamespaceBindingIdentity,
|
||||
} from '../../../src/core/ingestion/languages/python/import-target.js';
|
||||
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
||||
|
||||
// `collectNamespaceTargets` reads exactly two things: the file's module scope
|
||||
// and `scopes.imports`. Hand-building those keeps this test about the keying
|
||||
// The module-only cases need just a module scope and `scopes.imports`; the
|
||||
// lexical cases also supply parent scopes. This keeps the test about the keying
|
||||
// rule itself rather than about any one language's parser — which matters,
|
||||
// because the rule's whole job is to tell otherwise identical-looking edges
|
||||
// from different languages apart.
|
||||
|
|
@ -29,6 +32,7 @@ function collectPython(edges: readonly ImportEdge[], files: readonly string[] =
|
|||
const present = new Set(files);
|
||||
return collectNamespaceTargets(parsed, scopes, {
|
||||
receiverPaths: pythonNamespaceReceiverPaths,
|
||||
bindingIdentity: pythonNamespaceBindingIdentity,
|
||||
moduleFileExists: (filePath) => present.has(filePath),
|
||||
});
|
||||
}
|
||||
|
|
@ -57,7 +61,7 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
// happened to export `helper`, silently preferring a decoy over the real one.
|
||||
it('keys the package root at its own __init__, never at the leaf module', () => {
|
||||
const targets = collectPython([edge({})], ['pkg/__init__.py']);
|
||||
expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py']);
|
||||
expect(targets.get('pkg')).toEqual(['pkg/__init__.py']);
|
||||
expect(targets.get('pkg.db')).toEqual(['pkg/db.py']);
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +69,7 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
// PEP-420 namespace package: no __init__.py. Better no key than one
|
||||
// pointing at a file that does not exist — or at the wrong file.
|
||||
const targets = collectPython([edge({})], []);
|
||||
expect(targets.get('pkg')).toEqual(['pkg/db.py']);
|
||||
expect(targets.has('pkg')).toBe(false);
|
||||
expect(targets.get('pkg.db')).toEqual(['pkg/db.py']);
|
||||
});
|
||||
|
||||
|
|
@ -76,8 +80,8 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
targetFile: 'a/b/c.py',
|
||||
});
|
||||
const targets = collectPython([deep], ['a/__init__.py', 'a/b/__init__.py']);
|
||||
expect(targets.get('a')).toEqual(['a/__init__.py', 'a/b/c.py']);
|
||||
expect(targets.get('a.b')).toEqual(['a/b/__init__.py', 'a/b/c.py']);
|
||||
expect(targets.get('a')).toEqual(['a/__init__.py']);
|
||||
expect(targets.get('a.b')).toEqual(['a/b/__init__.py']);
|
||||
expect(targets.get('a.b.c')).toEqual(['a/b/c.py']);
|
||||
});
|
||||
|
||||
|
|
@ -102,13 +106,30 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
|
||||
it('does not key an alias import under the module path it does not bind', () => {
|
||||
// `import pkg.db as pdb` binds ONLY `pdb`; `pkg.db.f()` is a NameError.
|
||||
const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db' });
|
||||
const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db', explicitAlias: true });
|
||||
const targets = collectPython([aliased], ['pkg/__init__.py']);
|
||||
expect(targets.get('pdb')).toEqual(['pkg/db.py']);
|
||||
expect(targets.has('pkg.db')).toBe(false);
|
||||
expect(targets.has('pkg')).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['pkg/db.py', 'pkg/db/__init__.py'])(
|
||||
'keeps a root-spelled alias bound only to %s',
|
||||
(targetFile) => {
|
||||
const aliased = edge({ targetFile, explicitAlias: true });
|
||||
const targets = collectPython([aliased], ['pkg/__init__.py', targetFile]);
|
||||
expect([...targets]).toEqual([['pkg', [targetFile]]]);
|
||||
expect(
|
||||
pythonNamespaceBindingIdentity({
|
||||
localName: 'pkg',
|
||||
importPath: 'pkg.db',
|
||||
targetFile,
|
||||
explicitAlias: true,
|
||||
}),
|
||||
).toBe(targetFile);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps two same-package imports on separate keys', () => {
|
||||
const targets = collectPython(
|
||||
[
|
||||
|
|
@ -119,10 +140,9 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
);
|
||||
expect(targets.get('pkg.db')).toEqual(['pkg/db.py']);
|
||||
expect(targets.get('pkg.cache')).toEqual(['pkg/cache.py']);
|
||||
// The shared root LEADS with the package itself — not with whichever
|
||||
// submodule happened to be imported first — and keeps both leaves behind it
|
||||
// so a name merely re-exported by `__init__.py` still resolves.
|
||||
expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py', 'pkg/cache.py']);
|
||||
// Both imports bind the same root package, without publishing either
|
||||
// submodule's members on it.
|
||||
expect(targets.get('pkg')).toEqual(['pkg/__init__.py']);
|
||||
});
|
||||
|
||||
it('ignores non-namespace and unresolved edges', () => {
|
||||
|
|
@ -151,10 +171,7 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
[offRoot],
|
||||
['utils/__init__.py', 'libs/common/utils/__init__.py'],
|
||||
);
|
||||
expect(targets.get('utils')).toEqual([
|
||||
'libs/common/utils/__init__.py',
|
||||
'libs/common/utils/db.py',
|
||||
]);
|
||||
expect(targets.get('utils')).toEqual(['libs/common/utils/__init__.py']);
|
||||
expect(targets.get('utils.db')).toEqual(['libs/common/utils/db.py']);
|
||||
});
|
||||
|
||||
|
|
@ -165,8 +182,8 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
targetFile: 'src/a/b/c.py',
|
||||
});
|
||||
const targets = collectPython([srcLayout], ['src/a/__init__.py', 'src/a/b/__init__.py']);
|
||||
expect(targets.get('a')).toEqual(['src/a/__init__.py', 'src/a/b/c.py']);
|
||||
expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py', 'src/a/b/c.py']);
|
||||
expect(targets.get('a')).toEqual(['src/a/__init__.py']);
|
||||
expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py']);
|
||||
expect(targets.get('a.b.c')).toEqual(['src/a/b/c.py']);
|
||||
});
|
||||
|
||||
|
|
@ -178,4 +195,145 @@ describe('collectNamespaceTargets — namespace receiver spellings (#2826)', ()
|
|||
});
|
||||
expect(collectPython([bare]).get('single')).toEqual(['single.py']);
|
||||
});
|
||||
|
||||
it('keeps each prefix on its own package when the leaf is an __init__.py', () => {
|
||||
const packageLeaf = edge({
|
||||
localName: 'a',
|
||||
targetExportedName: 'a.b.c',
|
||||
targetFile: 'src/a/b/c/__init__.py',
|
||||
});
|
||||
const targets = collectPython(
|
||||
[packageLeaf],
|
||||
['src/a/__init__.py', 'src/a/b/__init__.py', 'src/a/b/c/__init__.py'],
|
||||
);
|
||||
expect(targets.get('a')).toEqual(['src/a/__init__.py']);
|
||||
expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py']);
|
||||
expect(targets.get('a.b.c')).toEqual(['src/a/b/c/__init__.py']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectNamespaceTargets — lexical import scopes', () => {
|
||||
const inner = 'scope:inner' as ScopeId;
|
||||
const sibling = 'scope:sibling' as ScopeId;
|
||||
const closure = 'scope:closure' as ScopeId;
|
||||
const parsed = { moduleScope: MODULE_SCOPE };
|
||||
const parents = new Map([
|
||||
[inner, MODULE_SCOPE],
|
||||
[sibling, MODULE_SCOPE],
|
||||
[closure, inner],
|
||||
]);
|
||||
|
||||
function collect(inScope: ScopeId, innerEdge: ImportEdge = edge({ targetFile: 'local.py' })) {
|
||||
const scopes = {
|
||||
imports: new Map([
|
||||
[MODULE_SCOPE, [edge({})]],
|
||||
[inner, [innerEdge]],
|
||||
]),
|
||||
scopeTree: { getScope: (id: ScopeId) => ({ parent: parents.get(id) ?? null }) },
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
return collectNamespaceTargets(parsed, scopes, { inScope });
|
||||
}
|
||||
|
||||
it('uses the nearest import without leaking it to a sibling or the module', () => {
|
||||
expect(collect(inner).get('pkg')).toEqual(['local.py']);
|
||||
expect(collect(closure).get('pkg')).toEqual(['local.py']);
|
||||
expect(collect(sibling).get('pkg')).toEqual(['pkg/db.py']);
|
||||
expect(collect(MODULE_SCOPE).get('pkg')).toEqual(['pkg/db.py']);
|
||||
});
|
||||
|
||||
it('does not revive an outer namespace hidden by a named import', () => {
|
||||
expect(collect(inner, edge({ kind: 'named', targetFile: 'local.py' })).has('pkg')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not revive an outer namespace when the nearest import is unresolved', () => {
|
||||
expect(collect(inner, edge({ targetFile: null, linkStatus: 'unresolved' })).has('pkg')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
function compatibleTargets(outer: ImportEdge, nearest: ImportEdge) {
|
||||
const scopes = {
|
||||
imports: new Map([
|
||||
[MODULE_SCOPE, [outer]],
|
||||
[inner, [nearest]],
|
||||
]),
|
||||
scopeTree: { getScope: (id: ScopeId) => ({ parent: parents.get(id) ?? null }) },
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
return collectNamespaceTargets(parsed, scopes, {
|
||||
inScope: inner,
|
||||
receiverPaths: pythonNamespaceReceiverPaths,
|
||||
bindingIdentity: pythonNamespaceBindingIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
it('retains sibling paths only when their resolved root package agrees', () => {
|
||||
const nearest = edge({ targetExportedName: 'pkg.cache', targetFile: 'lib/pkg/cache.py' });
|
||||
expect(compatibleTargets(edge({ targetFile: 'lib/pkg/db.py' }), nearest).get('pkg.db')).toEqual(
|
||||
['lib/pkg/db.py'],
|
||||
);
|
||||
expect(compatibleTargets(edge({ targetFile: 'other/pkg/db.py' }), nearest).has('pkg.db')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes the same root object when the nearest import names the package directly', () => {
|
||||
const nearest = edge({ targetExportedName: 'pkg', targetFile: 'lib/pkg/__init__.py' });
|
||||
expect(compatibleTargets(edge({ targetFile: 'lib/pkg/db.py' }), nearest).get('pkg.db')).toEqual(
|
||||
['lib/pkg/db.py'],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retain outer root paths under an explicit alias to a submodule', () => {
|
||||
const nearest = edge({
|
||||
targetExportedName: 'pkg.cache',
|
||||
targetFile: 'pkg/cache.py',
|
||||
explicitAlias: true,
|
||||
});
|
||||
expect([...compatibleTargets(edge({}), nearest)]).toEqual([['pkg', ['pkg/cache.py']]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectNamespaceTargets — same-scope import ownership', () => {
|
||||
const first = edge({ localName: 'mod', targetFile: 'alpha.py', explicitAlias: true });
|
||||
const named = edge({ localName: 'mod', kind: 'named', targetFile: 'values.py' });
|
||||
const other = edge({ localName: 'mod', targetFile: 'beta.py', explicitAlias: true });
|
||||
const unresolved = edge({ localName: 'mod', targetFile: null, linkStatus: 'unresolved' });
|
||||
|
||||
it.each([
|
||||
['different namespaces', [first, other]],
|
||||
['namespace then named', [first, named]],
|
||||
['named then namespace', [named, first]],
|
||||
['namespace then unresolved', [first, unresolved]],
|
||||
['unresolved then namespace', [unresolved, first]],
|
||||
['repeated original after conflict', [first, other, first]],
|
||||
['named import between duplicates', [first, named, first]],
|
||||
] as const)('suppresses %s', (_name, imports) => {
|
||||
expect([...collectPython(imports)]).toEqual([]);
|
||||
});
|
||||
it('deduplicates repeated imports of the same namespace', () => {
|
||||
expect([...collectPython([first, first])]).toEqual([['mod', ['alpha.py']]]);
|
||||
});
|
||||
it('preserves multiple targets without a binding identity hook', () => {
|
||||
expect([...collectDefault([first, named, other])]).toEqual([['mod', ['alpha.py', 'beta.py']]]);
|
||||
});
|
||||
it('keeps a lone namespace whose binding identity cannot be established', () => {
|
||||
expect(collectPython([edge({ targetFile: 'db.py' })]).get('pkg.db')).toEqual(['db.py']);
|
||||
});
|
||||
it('does not revive an outer namespace after suppressing an inner conflict', () => {
|
||||
const inner = 'scope:conflicting' as ScopeId;
|
||||
const scopes = {
|
||||
imports: new Map([
|
||||
[MODULE_SCOPE, [first]],
|
||||
[inner, [first, named]],
|
||||
]),
|
||||
scopeTree: { getScope: () => ({ parent: MODULE_SCOPE }) },
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
expect([
|
||||
...collectNamespaceTargets({ moduleScope: MODULE_SCOPE }, scopes, {
|
||||
inScope: inner,
|
||||
receiverPaths: pythonNamespaceReceiverPaths,
|
||||
bindingIdentity: pythonNamespaceBindingIdentity,
|
||||
}),
|
||||
]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ describe('Python imports — interpretImport', () => {
|
|||
{
|
||||
kind: 'namespace',
|
||||
localName: 'np',
|
||||
explicitAlias: true,
|
||||
importedName: 'numpy',
|
||||
targetRaw: 'numpy',
|
||||
declaredAtScope: f.moduleScope,
|
||||
|
|
@ -223,6 +224,20 @@ describe('Python imports — interpretImport', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('keeps an explicit alias when it matches the imported path root', () => {
|
||||
const f = parse('import a.b as a\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'a',
|
||||
explicitAlias: true,
|
||||
importedName: 'a.b',
|
||||
targetRaw: 'a.b',
|
||||
declaredAtScope: f.moduleScope,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('case 13: `import a, b as c` decomposes into one ParsedImport per name', () => {
|
||||
const f = parse('import a, b as c\n');
|
||||
expect(f.parsedImports).toEqual([
|
||||
|
|
@ -236,6 +251,7 @@ describe('Python imports — interpretImport', () => {
|
|||
{
|
||||
kind: 'namespace',
|
||||
localName: 'c',
|
||||
explicitAlias: true,
|
||||
importedName: 'b',
|
||||
targetRaw: 'b',
|
||||
declaredAtScope: f.moduleScope,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ def locally_shadowed():
|
|||
def dotted():
|
||||
return pkg.models.User()
|
||||
|
||||
def reexported():
|
||||
return pkg.User()
|
||||
|
||||
def missing_package_member():
|
||||
return pkg.Hidden()
|
||||
|
||||
def dotted_root_shadowed():
|
||||
pkg = object()
|
||||
return pkg.models.User()
|
||||
|
|
@ -41,15 +47,33 @@ def dotted_root_shadowed():
|
|||
],
|
||||
[
|
||||
'pkg/models.py',
|
||||
`class User:
|
||||
`from decoy import Hidden
|
||||
|
||||
class User:
|
||||
pass
|
||||
`,
|
||||
],
|
||||
['pkg/__init__.py', '# package marker\n'],
|
||||
['pkg/__init__.py', 'from .models import User\n'],
|
||||
[
|
||||
'pkg/package_leaf.py',
|
||||
`import pkg.subpkg
|
||||
|
||||
def from_root():
|
||||
return pkg.User()
|
||||
|
||||
def from_leaf():
|
||||
return pkg.subpkg.User()
|
||||
`,
|
||||
],
|
||||
['pkg/subpkg/__init__.py', 'from .models import User\n'],
|
||||
['pkg/subpkg/models.py', 'class User:\n pass\n'],
|
||||
[
|
||||
'decoy.py',
|
||||
`class Missing:
|
||||
pass
|
||||
|
||||
class Hidden:
|
||||
pass
|
||||
`,
|
||||
],
|
||||
]);
|
||||
|
|
@ -99,14 +123,18 @@ function build() {
|
|||
return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, {
|
||||
constructionSyntax: { bare: true },
|
||||
namespaceTargets,
|
||||
namespaceExportsIncludeImportedNames:
|
||||
pythonScopeResolver.namespaceExportsIncludeImportedNames,
|
||||
});
|
||||
};
|
||||
|
||||
const dotted = parsedFiles.find((file) => file.filePath === 'pkg/dotted.py');
|
||||
if (dotted === undefined) throw new Error('missing dotted fixture');
|
||||
const dottedNamespaceTargets = namespaceTargetsFor(dotted);
|
||||
|
||||
const resolveDottedIn = (functionName: string, expression: string) => {
|
||||
const resolveDottedIn = (
|
||||
functionName: string,
|
||||
expression: string,
|
||||
filePath = 'pkg/dotted.py',
|
||||
) => {
|
||||
const dotted = parsedFiles.find((file) => file.filePath === filePath);
|
||||
if (dotted === undefined) throw new Error(`missing fixture ${filePath}`);
|
||||
const functionScope = dotted.scopes.find(
|
||||
(scope) =>
|
||||
scope.kind === 'Function' &&
|
||||
|
|
@ -115,7 +143,9 @@ function build() {
|
|||
if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`);
|
||||
return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, {
|
||||
constructionSyntax: { bare: true },
|
||||
namespaceTargets: dottedNamespaceTargets,
|
||||
namespaceTargets: namespaceTargetsFor(dotted),
|
||||
namespaceExportsIncludeImportedNames:
|
||||
pythonScopeResolver.namespaceExportsIncludeImportedNames,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -153,6 +183,28 @@ describe('Python module namespace construction', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not treat a class re-exported from the imported leaf as ambiguous', () => {
|
||||
expect(resolveDottedIn('reexported', 'pkg.User()')).toMatchObject({
|
||||
filePath: 'pkg/models.py',
|
||||
qualifiedName: 'User',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose an imported leaf member on the package', () => {
|
||||
expect(resolveDottedIn('missing_package_member', 'pkg.Hidden()')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves root and leaf re-exports from their own package namespaces', () => {
|
||||
expect(resolveDottedIn('from_root', 'pkg.User()', 'pkg/package_leaf.py')).toMatchObject({
|
||||
filePath: 'pkg/models.py',
|
||||
qualifiedName: 'User',
|
||||
});
|
||||
expect(resolveDottedIn('from_leaf', 'pkg.subpkg.User()', 'pkg/package_leaf.py')).toMatchObject({
|
||||
filePath: 'pkg/subpkg/models.py',
|
||||
qualifiedName: 'User',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reuse a dotted namespace when a local shadows its ROOT segment', () => {
|
||||
// Fails without the root-segment fix: testing the whole `pkg.models`
|
||||
// string against scope bindings never matches, so the guard would pass a
|
||||
|
|
|
|||
|
|
@ -16,19 +16,24 @@
|
|||
* about which module owns `scale`.
|
||||
*
|
||||
* WHY THE INDEXES ARE HAND-BUILT, stated so this is not read as a fixture that
|
||||
* "just happens" to be synthetic. Zig forbids declaring a name twice in one
|
||||
* container, and Zig is today the only provider that sets
|
||||
* `namespaceExportsIncludeImportedNames`, so no valid Zig source can put a
|
||||
* local non-callable and a published callable under one name in one module —
|
||||
* there is no source-level fixture to write. The shape becomes reachable the
|
||||
* moment a second provider opts in, or a receiver name binds more than one
|
||||
* target file. Building the indexes directly is what lets the guard be pinned
|
||||
* before that happens; the middle case below fails without it.
|
||||
* "just happens" to be synthetic. Python and Zig both set
|
||||
* `namespaceExportsIncludeImportedNames`; Zig forbids declaring a name twice
|
||||
* in one container, so its source cannot express the overlapping bindings
|
||||
* tested here. Building the indexes directly isolates the shared precedence
|
||||
* contract from provider extraction and finalization. The middle case below
|
||||
* fails without the local-name guard.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveValueRefTarget } from '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js';
|
||||
import type { BindingRef, ReferenceSite, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
BindingRef,
|
||||
ImportEdge,
|
||||
ReferenceSite,
|
||||
Scope,
|
||||
ScopeId,
|
||||
SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
||||
import type { SemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
|
||||
|
||||
|
|
@ -125,6 +130,12 @@ describe('findNamespaceValueRefTarget — local declarations outrank re-publishe
|
|||
);
|
||||
});
|
||||
|
||||
it('resolves a callable published through a wildcard import', () => {
|
||||
expect(resolve(indexes({ published: [ref(PUBLISHED, 'wildcard')] }))?.nodeId).toBe(
|
||||
PUBLISHED.nodeId,
|
||||
);
|
||||
});
|
||||
|
||||
it('declines when the hub declares a NON-callable under the name', () => {
|
||||
// The regression. The local lookup type-gates before precedence is settled,
|
||||
// so `scale` answered nothing locally and the published channel bound
|
||||
|
|
@ -168,3 +179,76 @@ describe('findNamespaceValueRefTarget — local declarations outrank re-publishe
|
|||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('namespace value references follow lexical visibility', () => {
|
||||
const INNER = 'scope:inner';
|
||||
const SIBLING = 'scope:sibling';
|
||||
const CLASS = 'scope:class';
|
||||
const METHOD = 'scope:method';
|
||||
const OTHER = 'scope:other:module';
|
||||
const OTHER_FILE = 'src/other.zig';
|
||||
const OTHER_FN = def('Function:src/other.zig:scale', 'Function', OTHER_FILE);
|
||||
|
||||
function fixture(edge: Partial<ImportEdge> = {}, importScope = INNER) {
|
||||
const base = indexes({ local: [ref(LOCAL_FN, 'local')] });
|
||||
const extraScopes = new Map<ScopeId, Scope>([
|
||||
[INNER, { ...moduleScope(INNER, CONSUMER_FILE), kind: 'Function', parent: CONSUMER }],
|
||||
[SIBLING, { ...moduleScope(SIBLING, CONSUMER_FILE), kind: 'Function', parent: CONSUMER }],
|
||||
[CLASS, { ...moduleScope(CLASS, CONSUMER_FILE), kind: 'Class', parent: INNER }],
|
||||
[METHOD, { ...moduleScope(METHOD, CONSUMER_FILE), kind: 'Function', parent: CLASS }],
|
||||
[OTHER, moduleScope(OTHER, OTHER_FILE)],
|
||||
]);
|
||||
return {
|
||||
...base,
|
||||
scopeTree: { getScope: (id: ScopeId) => extraScopes.get(id) ?? base.scopeTree.getScope(id) },
|
||||
moduleScopes: {
|
||||
get: (file: string) => (file === OTHER_FILE ? OTHER : base.moduleScopes.get(file)),
|
||||
},
|
||||
imports: new Map([
|
||||
...base.imports,
|
||||
[
|
||||
importScope,
|
||||
[
|
||||
{
|
||||
localName: 'hub',
|
||||
targetExportedName: 'other',
|
||||
targetFile: OTHER_FILE,
|
||||
kind: 'namespace',
|
||||
...edge,
|
||||
} as ImportEdge,
|
||||
],
|
||||
],
|
||||
]),
|
||||
bindings: new Map([
|
||||
...base.bindings,
|
||||
[OTHER, new Map([['scale', [ref(OTHER_FN, 'local')]]])],
|
||||
]),
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
}
|
||||
|
||||
function at(scope: ScopeId, scopes: ScopeResolutionIndexes) {
|
||||
return resolveValueRefTarget({ ...SITE, inScope: scope }, CONSUMER_FILE, scopes, MODEL, true, {
|
||||
skipEnclosingClasses: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('selects the nearest import for a registration without leaking it to a sibling', () => {
|
||||
const scopes = fixture();
|
||||
expect(at(INNER, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
|
||||
expect(at(METHOD, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
|
||||
expect(at(SIBLING, scopes)?.nodeId).toBe(LOCAL_FN.nodeId);
|
||||
});
|
||||
|
||||
it.each([{ kind: 'named' as const }, { targetFile: null, linkStatus: 'unresolved' as const }])(
|
||||
'does not revive the outer namespace under a shadowing import: %j',
|
||||
(edge) => {
|
||||
expect(at(INNER, fixture(edge))).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('skips an enclosing class import but retains it for class-body registrations', () => {
|
||||
const scopes = fixture({}, CLASS);
|
||||
expect(at(CLASS, scopes)?.nodeId).toBe(OTHER_FN.nodeId);
|
||||
expect(at(METHOD, scopes)?.nodeId).toBe(LOCAL_FN.nodeId);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue