GitNexus/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts

110 lines
3.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ParsedFile } from 'gitnexus-shared';
import { pythonScopeResolver } from '../../../../src/core/ingestion/languages/python/scope-resolver.js';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js';
import { resolveCompoundReceiverClass } from '../../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js';
import { collectNamespaceTargets } from '../../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js';
import { buildWorkspaceResolutionIndex } from '../../../../src/core/ingestion/scope-resolution/workspace-index.js';
const SOURCES = new Map([
[
'pkg/app.py',
`from pkg import models
from decoy import Missing
def valid():
return models.User()
def missing():
return models.Missing()
def shadowed(models):
return models.User()
def locally_shadowed():
models = object()
return models.User()
`,
],
[
'pkg/models.py',
`class User:
pass
`,
],
['pkg/__init__.py', '# package marker\n'],
[
'decoy.py',
`class Missing:
pass
`,
],
]);
function build() {
const parsedFiles: ParsedFile[] = [];
for (const [filePath, source] of SOURCES) {
const parsed = extractParsedFile(pythonScopeResolver.languageProvider, source, filePath);
if (parsed === undefined) throw new Error(`scope extraction failed for ${filePath}`);
pythonScopeResolver.populateOwners(parsed);
parsedFiles.push(parsed);
}
const allFilePaths = new Set(parsedFiles.map((file) => file.filePath));
const scopes = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) =>
pythonScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths, undefined, {
parsedFiles,
parsedImport,
}),
isNamespaceImport: (parsedImport, targetFile, fromFile) =>
pythonScopeResolver.isNamespaceImport?.(parsedImport, targetFile, fromFile) ?? false,
mergeBindings: (existing, incoming, scopeId) =>
pythonScopeResolver.mergeBindings(existing, incoming, scopeId),
},
});
const index = buildWorkspaceResolutionIndex(parsedFiles);
const app = parsedFiles.find((file) => file.filePath === 'pkg/app.py');
if (app === undefined) throw new Error('missing app fixture');
const namespaceTargets = collectNamespaceTargets(app, scopes);
const resolveIn = (functionName: string, expression: string) => {
const functionScope = app.scopes.find(
(scope) =>
scope.kind === 'Function' &&
scope.ownedDefs.some((def) => def.qualifiedName === functionName),
);
if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`);
return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, {
constructionSyntax: { bare: true },
namespaceTargets,
});
};
return { resolveIn };
}
describe('Python module namespace construction', () => {
const { resolveIn } = build();
it('resolves an exported class from the verified module target', () => {
expect(resolveIn('valid', 'models.User()')).toMatchObject({
filePath: 'pkg/models.py',
qualifiedName: 'User',
});
});
it('fails closed when the module lacks the requested class', () => {
expect(resolveIn('missing', 'models.Missing()')).toBeUndefined();
});
it('does not reuse a file-level namespace when a parameter shadows it', () => {
expect(resolveIn('shadowed', 'models.User()')).toBeUndefined();
});
it('does not reuse a file-level namespace when a local shadows it', () => {
expect(resolveIn('locally_shadowed', 'models.User()')).toBeUndefined();
});
});