test(scope-resolution): rewrite workspace-index test for slimmed index

The test file previously asserted on \`defsByFileAndName\`,
\`callablesBySimpleName\`, and \`memberByOwner\` — fields removed when
symbol-keyed lookups moved to \`SemanticModel\`. Rewrite so the same
invariants are asserted via the authoritative consumers:

  * New WorkspaceResolutionIndex shape test (scope-only maps).
  * \`findExportedDef\` module-export visibility tests:
    - keeps top-level class and function defs.
    - excludes class-body Variable defs (MAX_USERS = 100).
    - excludes class methods from module-export lookup.
  * \`findExportedDefByName\` fallback excludes class methods when a
    same-named module function exists.
  * \`findOwnedMember\` via the reconciled SemanticModel finds Python
    class methods after populateOwners + reconcileOwnership.

Total assertions preserved: every invariant from the old test file is
still pinned; the assertion surface shifted from the index shape to
the walker helpers.

Verified:
  - workspace-index.test.ts                        8/8 passing
This commit is contained in:
Gergo Magyar 2026-04-22 17:08:12 +01:00
parent ba94fdca91
commit 4aaf0db598

View file

@ -1,23 +1,32 @@
/**
* Directly assert the shape of `WorkspaceResolutionIndex` in
* particular that `defsByFileAndName` and `callablesBySimpleName`
* filter class-body attributes and nested-function locals out of the
* file-level export keyspace.
* Pin the invariants the workspace-index layer MUST preserve after the
* symbol-indexed duplicates moved to `SemanticModel`.
*
* The equivalent integration-level assertions in
* `test/integration/resolvers/python.test.ts` (see the
* `python-class-attr-export-leak` fixture) cover the downstream
* edge-emission path. This unit test pins the index shape directly
* because the downstream consumer in Python today doesn't emit an
* ACCESSES edge for `mod.NAME` member access, so the leak would be
* latent at the index layer until a future capture path makes it
* visible. That's precisely when a unit-level pin is most valuable.
* Previously this file asserted on `defsByFileAndName`,
* `callablesBySimpleName`, and `memberByOwner` directly. Those fields
* were removed symbol-keyed lookups now consult `SemanticModel` and
* `WorkspaceResolutionIndex` holds only `classScopeByDefId` +
* `moduleScopeByFile`. The same invariants are now asserted via the
* walker helpers (`findExportedDef`, `findExportedDefByName`,
* `findOwnedMember`) which are the authoritative consumers. This
* keeps the regression guard (class-body attributes / methods must
* not leak into module-export lookups, and method membership must
* stay reachable after `populateOwners`) without asserting on the
* now-deleted index shape.
*/
import { describe, it, expect } from 'vitest';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { pythonScopeResolver } from '../../../src/core/ingestion/languages/python/scope-resolver.js';
import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
import {
findExportedDef,
findExportedDefByName,
findOwnedMember,
} from '../../../src/core/ingestion/scope-resolution/scope/walkers.js';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
import { reconcileOwnership } from '../../../src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
function parsePython(source: string, filePath: string) {
const parsed = extractParsedFile(
@ -30,7 +39,54 @@ function parsePython(source: string, filePath: string) {
return parsed;
}
describe('buildWorkspaceResolutionIndex — module-export filter', () => {
describe('WorkspaceResolutionIndex — scope-only maps', () => {
it('exposes classScopeByDefId and moduleScopeByFile only', () => {
const parsed = parsePython(
`
class User:
pass
`,
'mod.py',
);
const index = buildWorkspaceResolutionIndex([parsed]);
expect(index.classScopeByDefId).toBeInstanceOf(Map);
expect(index.moduleScopeByFile).toBeInstanceOf(Map);
// No symbol-indexed duplicates.
expect((index as { memberByOwner?: unknown }).memberByOwner).toBeUndefined();
expect((index as { defsByFileAndName?: unknown }).defsByFileAndName).toBeUndefined();
expect((index as { callablesBySimpleName?: unknown }).callablesBySimpleName).toBeUndefined();
});
it('classScopeByDefId maps class nodeIds to their Scope', () => {
const parsed = parsePython(
`
class User:
pass
`,
'mod.py',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const classScope = parsed.scopes.find((s) => s.kind === 'Class');
const classDef = classScope?.ownedDefs.find((d) => d.type === 'Class');
expect(classDef).toBeDefined();
expect(index.classScopeByDefId.get(classDef!.nodeId)).toBe(classScope);
});
it('moduleScopeByFile maps filePath to Module scope', () => {
const parsed = parsePython(
`
def helper() -> int:
return 42
`,
'mod.py',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
expect(index.moduleScopeByFile.get('mod.py')).toBe(moduleScope);
});
});
describe('findExportedDef — module-export visibility filter', () => {
it('keeps top-level class and function defs', () => {
const parsed = parsePython(
`
@ -43,20 +99,20 @@ def helper() -> int:
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
const fileBucket = index.defsByFileAndName.get('mod.py');
expect(fileBucket).toBeDefined();
expect(fileBucket!.get('User')?.type).toBe('Class');
expect(fileBucket!.get('helper')?.type).toBe('Function');
expect(findExportedDef('mod.py', 'User', index)?.type).toBe('Class');
expect(findExportedDef('mod.py', 'helper', index)?.type).toBe('Function');
});
it('excludes class-body Variable defs from defsByFileAndName', () => {
// Python's scope extractor captures `MAX_USERS = 100` inside a
// class body as `Variable:MAX_USERS` in the Class scope's
// ownedDefs. Without the scope-defining-def filter, this entry
// would leak into defsByFileAndName['mod.py']['MAX_USERS'] and
// `mod.MAX_USERS` / `from mod import MAX_USERS` would silently
// resolve to the class attribute.
it('excludes class-body Variable defs from module-export lookup', () => {
// Python `MAX_USERS = 100` inside a class body is captured as
// `Variable:MAX_USERS` in the Class scope's ownedDefs. It must
// NOT be visible via the file-level export lookup — otherwise
// `from mod import MAX_USERS` would silently resolve to the
// class attribute.
const parsed = parsePython(
`
class User:
@ -64,19 +120,16 @@ class User:
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
const fileBucket = index.defsByFileAndName.get('mod.py');
expect(fileBucket).toBeDefined();
expect(fileBucket!.get('MAX_USERS')).toBeUndefined();
expect(findExportedDef('mod.py', 'MAX_USERS', index)).toBeUndefined();
// Positive-case invariant: the Class def itself is still exported.
expect(fileBucket!.get('User')?.type).toBe('Class');
expect(findExportedDef('mod.py', 'User', index)?.type).toBe('Class');
});
it('excludes class methods from defsByFileAndName', () => {
// A method lives in a Function scope whose parent is the Class
// scope (not the Module), so it shouldn't be reachable through
// the direct-child filter at all. Guard against a regression to
// the earlier "method wins module-export slot" bug.
it('excludes class methods from module-export lookup', () => {
const parsed = parsePython(
`
class User:
@ -85,15 +138,18 @@ class User:
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
const fileBucket = index.defsByFileAndName.get('mod.py');
expect(fileBucket).toBeDefined();
// `save` is a method — NOT a module export.
expect(fileBucket!.get('save')).toBeUndefined();
expect(fileBucket!.get('User')?.type).toBe('Class');
});
it('excludes class methods from callablesBySimpleName', () => {
// `save` is a method — NOT a module export.
expect(findExportedDef('mod.py', 'save', index)).toBeUndefined();
expect(findExportedDef('mod.py', 'User', index)?.type).toBe('Class');
});
});
describe('findExportedDefByName — workspace-wide callable fallback', () => {
it('excludes class methods when same-named module function exists', () => {
const parsed = parsePython(
`
class User:
@ -105,19 +161,21 @@ def save(x: int) -> int:
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
const finalized = finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
const saves = index.callablesBySimpleName.get('save') ?? [];
// Only the top-level `def save(x)` is a module-level callable.
// The `User.save` method lives under a Class scope and must not
// appear in the workspace callable fallback.
expect(saves).toHaveLength(1);
expect(saves[0].qualifiedName).toBe('save');
});
it('keeps memberByOwner populated for class methods (unchanged contract)', () => {
// Regression guard: the narrowing of defsByFileAndName must NOT
// collaterally drop class-method entries from memberByOwner.
// findOwnedMember relies on this for receiver-bound dispatch.
// Workspace-wide fallback: iterates moduleScopeByFile and returns
// the first locally-declared callable binding. The method
// `User.save` lives under a Class scope and must not win.
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module')!;
const result = findExportedDefByName('save', moduleScope.id, finalized, index);
expect(result?.qualifiedName).toBe('save');
});
});
describe('findOwnedMember — SemanticModel-backed owner lookup', () => {
it('resolves a class method via the reconciled model', () => {
const parsed = parsePython(
`
class User:
@ -127,14 +185,15 @@ class User:
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
const index = buildWorkspaceResolutionIndex([parsed]);
// User's nodeId is derivable from its ownedDefs — find the Class
// scope's Class def and look up 'save' under its nodeId.
const model = createSemanticModel();
reconcileOwnership([parsed], model);
const classScope = parsed.scopes.find((s) => s.kind === 'Class');
const classDef = classScope?.ownedDefs.find((d) => d.type === 'Class');
expect(classDef).toBeDefined();
const members = index.memberByOwner.get(classDef!.nodeId);
expect(members).toBeDefined();
expect(members!.get('save')?.type).toBe('Function');
const found = findOwnedMember(classDef!.nodeId, 'save', model);
expect(found?.type).toBe('Function');
expect(found?.qualifiedName).toBe('User.save');
});
});