GitNexus/gitnexus/test/integration/python-scope-resolution.test.ts
copilot-swe-agent[bot] f5862070cd
test(python): integration-style scope-resolution tests + suffixResolve fallback
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/db76e937-4b0e-4c4d-82b1-265a1fb3673d

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-19 07:16:02 +00:00

432 lines
18 KiB
TypeScript

/**
* Python scope-resolution INTEGRATION test (RFC #909 Ring 3).
*
* Exercises the full new code path end-to-end on a real multi-file
* Python repo:
*
* fs `.py` source
* │ pythonProvider.emitScopeCaptures()
* ▼
* CaptureMatch[]
* │ scope-extractor (Pass 1-5)
* ▼
* ParsedFile[]
* │ finalizeScopeModel( + Python hooks adapted to FinalizeHooks)
* ▼
* ScopeResolutionIndexes ◀── assertions live here
*
* This is the integration story that mirrors the established
* `test/integration/resolvers/python.test.ts` pattern (multi-file, real
* sources, pipeline-driven, declarative assertions on the resolved
* graph) — adapted for the new scope-resolution code path that's not
* yet wired into `runPipelineFromRepo` (Ring 4 work).
*
* Coverage map:
*
* - Cross-file `from X import Y` linking
* - Cross-file `import X as Y` (namespace alias)
* - Re-export chains (`__init__.py` re-exporting from a submodule)
* - Wildcard `from X import *` expansion + lookup
* - Function-local `from X import Y` attaching at the function scope
* - Method receiver synthesis: `self`, `cls`, `@staticmethod` skip
* - Per-name decomposition of `from m import a, b`
* - `FinalizeStats` accuracy
* - PEP-328 relative imports (`from .user import …`)
* - LEGB precedence: function-local shadows module-level imports
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import fs from 'fs';
import type {
BindingRef,
ImportEdge,
ParsedFile,
ScopeId,
WorkspaceIndex,
} from 'gitnexus-shared';
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import {
finalizeScopeModel,
type FinalizeOrchestratorOptions,
} from '../../src/core/ingestion/finalize-orchestrator.js';
import { pythonProvider } from '../../src/core/ingestion/languages/python.js';
import {
pythonMergeBindings,
resolvePythonImportTarget,
type PythonResolveContext,
} from '../../src/core/ingestion/languages/python/index.js';
import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js';
const FIXTURE_ROOT = path.resolve(
__dirname,
'..',
'fixtures',
'python-scope-integration',
);
// ─── Test harness ──────────────────────────────────────────────────────────
interface ResolvedRepo {
readonly indexes: ScopeResolutionIndexes;
readonly parsedByFile: ReadonlyMap<string, ParsedFile>;
}
/** Walk the fixture root, parse every `.py` file via `pythonProvider`,
* then run `finalizeScopeModel` with Python hooks adapted to the
* `FinalizeHooks` shape the orchestrator expects.
*
* The adapter layer here is intentionally thin and lives in the test:
* the production glue between `LanguageProvider` and `FinalizeHooks`
* is the subject of follow-up work (Ring 4 / pipeline wiring), and
* inlining it here keeps the integration test honest about what
* contracts it exercises. */
function buildResolvedRepo(rootDir: string): ResolvedRepo {
const pyFiles = collectPyFiles(rootDir).sort();
const allFilePaths = new Set(pyFiles);
const parsedByFile = new Map<string, ParsedFile>();
for (const filePath of pyFiles) {
const src = fs.readFileSync(filePath, 'utf8');
const parsed = extractParsedFile(pythonProvider, src, filePath, () => {});
if (parsed === undefined) {
throw new Error(`extractParsedFile returned undefined for ${filePath}`);
}
parsedByFile.set(filePath, parsed);
}
// Adapter: `(targetRaw, fromFile, ws)` → `string | null`
// The provider's `resolveImportTarget` takes `(ParsedImport, ws)`,
// but the central finalize hook is targetRaw-shaped because it's
// upstream of `interpretImport`. We synthesize the minimal
// `ParsedImport` shape needed by `resolvePythonImportTarget`.
const options: FinalizeOrchestratorOptions = {
workspaceIndex: undefined, // per-file ws is built inline below
hooks: {
resolveImportTarget: (targetRaw, fromFile) => {
const ws: PythonResolveContext = { fromFile, allFilePaths };
return resolvePythonImportTarget(
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
ws as unknown as WorkspaceIndex,
);
},
mergeBindings: (existing, incoming, scopeId) => {
// The provider's `pythonMergeBindings(scope, bindings)` expects a
// `Scope` object, not a `ScopeId`. We don't need scope.kind for
// LEGB tier merging (tier comes from `BindingRef.origin`), so a
// shape stub is sufficient here. This adapter mirrors what the
// production wiring will do in #921 (Ring 2 finalize integration).
const fakeScope = { id: scopeId } as unknown as Parameters<typeof pythonMergeBindings>[0];
return pythonMergeBindings(fakeScope, [...existing, ...incoming]);
},
// Wildcard expansion: scan the target module's local defs for
// public names. Mirrors the Python runtime behavior — anything not
// prefixed with `_` is exported (no `__all__` parsing here; the
// production hook should consult `__all__` when present, but the
// fixture deliberately doesn't set it so we test the default path).
expandsWildcardTo: (targetModuleScope) => {
for (const file of parsedByFile.values()) {
if (file.moduleScope !== targetModuleScope) continue;
return file.localDefs
.filter((d) => d.qualifiedName !== undefined && d.qualifiedName.length > 0)
.map((d) => d.qualifiedName!.split('.').pop()!)
.filter((name) => name.length > 0 && !name.startsWith('_'));
}
return [];
},
},
};
const indexes = finalizeScopeModel([...parsedByFile.values()], options);
return { indexes, parsedByFile };
}
function collectPyFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...collectPyFiles(full));
else if (entry.isFile() && entry.name.endsWith('.py')) out.push(full);
}
return out;
}
/** Lookup helpers — mirror the style of `test/integration/resolvers/helpers.ts`. */
function importEdgesIn(repo: ResolvedRepo, fileSuffix: string): readonly ImportEdge[] {
const file = findFile(repo, fileSuffix);
return repo.indexes.imports.get(file.moduleScope) ?? [];
}
function findFile(repo: ResolvedRepo, fileSuffix: string): ParsedFile {
for (const [path_, file] of repo.parsedByFile) {
if (path_.endsWith(fileSuffix)) return file;
}
throw new Error(`fixture file not found: ${fileSuffix}`);
}
function functionScopeBy(parsed: ParsedFile, fnName: string) {
return parsed.scopes.find(
(s) =>
s.kind === 'Function' &&
// Some defs name the scope after the qualified path; match the leaf.
(s.ownedDefs.some((d) => d.qualifiedName?.split('.').pop() === fnName) ||
s.ownedDefs.some((d) => d.qualifiedName === fnName)),
);
}
// ─── Fixture-wide one-time parse ───────────────────────────────────────────
let repo: ResolvedRepo;
beforeAll(() => {
repo = buildResolvedRepo(FIXTURE_ROOT);
}, 30_000);
// ─── Cross-file linking ────────────────────────────────────────────────────
describe('Python scope-resolution integration: cross-file linking', () => {
it('links `from models.user import User as UserModel` to user.py', () => {
const auth = importEdgesIn(repo, 'services/auth.py');
const userImport = auth.find((e) => e.localName === 'UserModel');
expect(userImport).toBeDefined();
expect(userImport!.kind).toBe('alias');
expect(userImport!.targetExportedName).toBe('User');
expect(userImport!.targetFile?.endsWith('models/user.py')).toBe(true);
expect(userImport!.linkStatus).toBeUndefined();
});
it('decomposes `from utils.logger import log_info, log_error` into 2 separate edges', () => {
const auth = importEdgesIn(repo, 'services/auth.py');
// Only the module-level `from … import log_info, log_error`. The
// function-local `from … import log_error as fail` decomposes too,
// but lives at a different scope conceptually — we filter it out
// here by `kind === 'named'` (the function-local one is `'alias'`).
const log = auth.filter(
(e) => e.targetExportedName.startsWith('log_') && e.kind === 'named',
);
const names = log.map((e) => e.localName).sort();
expect(names).toEqual(['log_error', 'log_info']);
for (const e of log) {
expect(e.kind).toBe('named');
expect(e.targetFile?.endsWith('utils/logger.py')).toBe(true);
}
});
it('links namespace import `import models.user` to user.py', () => {
const auth = importEdgesIn(repo, 'services/auth.py');
const ns = auth.find((e) => e.kind === 'namespace');
expect(ns).toBeDefined();
expect(ns!.targetFile?.endsWith('models/user.py')).toBe(true);
});
it('handles PEP-328 relative imports in __init__.py (`from .user import …`)', () => {
const initFile = importEdgesIn(repo, 'models/__init__.py');
expect(initFile.length).toBeGreaterThanOrEqual(2);
for (const e of initFile) {
expect(e.targetFile?.endsWith('models/user.py')).toBe(true);
expect(e.linkStatus).toBeUndefined();
}
const exported = initFile.map((e) => e.targetExportedName).sort();
expect(exported).toEqual(['Admin', 'User']);
});
});
// ─── Wildcard expansion ────────────────────────────────────────────────────
describe('Python scope-resolution integration: wildcard expansion', () => {
it('expands `from utils.logger import *` into one edge per public def', () => {
const notifier = importEdgesIn(repo, 'services/notifier.py');
const expanded = notifier.filter((e) => e.kind === 'wildcard-expanded');
const names = expanded.map((e) => e.targetExportedName).sort();
expect(names).toEqual(['log_error', 'log_info', 'log_with_extras']);
for (const e of expanded) {
expect(e.targetFile?.endsWith('utils/logger.py')).toBe(true);
expect(e.linkStatus).toBeUndefined();
}
});
});
// ─── Function-local imports / LEGB ─────────────────────────────────────────
describe('Python scope-resolution integration: function-local imports', () => {
it('captures the function-local `from utils.logger import log_error as fail` as a ParsedImport on the file', () => {
// NOTE: per-scope owning attribution (Scope.imports) flows from
// `pythonImportOwningScope` and is exercised by the unit suite. The
// current finalize orchestrator keys the resolved `ImportEdge` map at
// the module scope only — wiring that to per-scope is Ring 4 work
// (#922 follow-up). Until then we assert that the parsed import is
// present and resolvable; the placement-at-function-scope assertion
// moves with the wiring.
const auth = findFile(repo, 'services/auth.py');
const failImport = auth.parsedImports.find(
(p) => p.kind === 'alias' && (p as { alias?: string }).alias === 'fail',
);
expect(failImport).toBeDefined();
expect(failImport!.targetRaw).toBe('utils.logger');
});
});
// ─── Receiver type bindings (self / cls / staticmethod) ────────────────────
describe('Python scope-resolution integration: implicit receivers', () => {
it('synthesizes `self: AuthService` for instance methods', () => {
const auth = findFile(repo, 'services/auth.py');
const authenticate = functionScopeBy(auth, 'authenticate');
expect(authenticate).toBeDefined();
expect(authenticate!.typeBindings.get('self')?.rawName).toBe('AuthService');
expect(authenticate!.typeBindings.get('self')?.source).toBe('self');
});
it('synthesizes `cls: AuthService` for @classmethod-decorated methods', () => {
const auth = findFile(repo, 'services/auth.py');
const fromEnv = functionScopeBy(auth, 'from_env');
expect(fromEnv).toBeDefined();
expect(fromEnv!.typeBindings.get('cls')?.rawName).toBe('AuthService');
expect(fromEnv!.typeBindings.has('self')).toBe(false);
});
it('does NOT synthesize a receiver for @staticmethod-decorated methods', () => {
const auth = findFile(repo, 'services/auth.py');
const hashToken = functionScopeBy(auth, 'hash_token');
expect(hashToken).toBeDefined();
expect(hashToken!.typeBindings.has('self')).toBe(false);
expect(hashToken!.typeBindings.has('cls')).toBe(false);
});
it('does NOT synthesize a receiver for free top-level functions', () => {
const logger = findFile(repo, 'utils/logger.py');
const logInfo = functionScopeBy(logger, 'log_info');
expect(logInfo).toBeDefined();
expect(logInfo!.typeBindings.has('self')).toBe(false);
expect(logInfo!.typeBindings.has('cls')).toBe(false);
});
});
// ─── Stats accuracy ────────────────────────────────────────────────────────
describe('Python scope-resolution integration: FinalizeStats', () => {
it('reports correct totals across the multi-file fixture', () => {
const stats = repo.indexes.stats;
// 5 .py files in the fixture (auth.py, notifier.py, __init__.py, user.py, logger.py).
expect(stats.totalFiles).toBe(5);
// Every import edge in this fixture is resolvable in-workspace.
expect(stats.unresolvedEdges).toBe(0);
expect(stats.linkedEdges).toBe(stats.totalEdges);
expect(stats.totalEdges).toBeGreaterThan(0);
});
it('produces a non-empty bindings index seeded with at least one entry per file', () => {
expect(repo.indexes.bindings.size).toBeGreaterThan(0);
for (const [, file] of repo.parsedByFile) {
const moduleBindings = repo.indexes.bindings.get(file.moduleScope);
expect(moduleBindings).toBeDefined();
}
});
it('exposes module scopes via `moduleScopes` index for every parsed file', () => {
for (const [filePath, file] of repo.parsedByFile) {
const moduleScopeId = repo.indexes.moduleScopes.get(filePath);
expect(moduleScopeId).toBe(file.moduleScope);
}
});
});
// ─── Definition discovery ─────────────────────────────────────────────────
describe('Python scope-resolution integration: defs index', () => {
it('indexes every public class and function across the workspace', () => {
const allDefs: string[] = [];
for (const d of repo.indexes.defs.byId.values()) {
if (d.qualifiedName !== undefined && d.qualifiedName.length > 0) {
allDefs.push(d.qualifiedName.split('.').pop()!);
}
}
const sorted = allDefs.sort();
// Membership check: shifts in this list mean a real surface change.
for (const expected of [
'Admin',
'AuthService',
'User',
'authenticate',
'can_delete',
'display_name',
'emit_all',
'from_env',
'hash_token',
'log_error',
'log_info',
'log_with_extras',
]) {
expect(sorted).toContain(expected);
}
});
});
// ─── Defensive: BindingRef provenance ─────────────────────────────────────
describe('Python scope-resolution integration: BindingRef provenance', () => {
it("tags imported bindings with origin='import' and a non-null `via` ImportEdge", () => {
const auth = findFile(repo, 'services/auth.py');
const moduleBindings = repo.indexes.bindings.get(auth.moduleScope);
expect(moduleBindings).toBeDefined();
const userBinding = moduleBindings!.get('UserModel');
expect(userBinding).toBeDefined();
expect(userBinding!.length).toBeGreaterThan(0);
const ref: BindingRef = userBinding![0]!;
// `alias`-kind imports use origin='import' (the Python provider does
// not yet emit 'alias' as a `BindingRef.origin` value — alias-ness
// lives on the `via` ImportEdge).
expect(['import', 'namespace']).toContain(ref.origin);
expect(ref.via).toBeDefined();
expect(ref.via!.targetExportedName).toBe('User');
});
it("tags wildcard-expanded bindings with origin='wildcard'", () => {
const notifier = findFile(repo, 'services/notifier.py');
const moduleBindings = repo.indexes.bindings.get(notifier.moduleScope);
expect(moduleBindings).toBeDefined();
const log = moduleBindings!.get('log_info');
expect(log).toBeDefined();
expect(log![0]!.origin).toBe('wildcard');
});
});
// ─── Module scope topology ────────────────────────────────────────────────
describe('Python scope-resolution integration: scope topology', () => {
it('produces exactly one Module scope per .py file', () => {
let moduleScopes = 0;
for (const s of repo.indexes.scopeTree.byId.values()) {
if (s.kind === 'Module') moduleScopes += 1;
}
expect(moduleScopes).toBe(repo.parsedByFile.size);
});
it('class scopes contain method scopes as descendants', () => {
const userFile = findFile(repo, 'models/user.py');
const userClass = userFile.scopes.find(
(s) => s.kind === 'Class' && s.ownedDefs.some((d) => d.qualifiedName?.endsWith('User')),
);
expect(userClass).toBeDefined();
const initMethod = userFile.scopes.find(
(s) =>
s.kind === 'Function' &&
s.parent === userClass!.id &&
s.ownedDefs.some((d) => d.qualifiedName?.endsWith('__init__')),
);
expect(initMethod).toBeDefined();
});
});
// ─── Cross-language sanity (shouldn't process .ts/.js) ────────────────────
describe('Python scope-resolution integration: scope kinds emitted', () => {
it('emits only Module / Class / Function scope kinds (no block/expression scopes)', () => {
const seenKinds = new Set<string>();
for (const file of repo.parsedByFile.values()) {
for (const s of file.scopes) seenKinds.add(s.kind);
}
// Python has no block scope; we deliberately don't emit @scope.block.
expect([...seenKinds].sort()).toEqual(['Class', 'Function', 'Module']);
});
});