From 4aaf0db598e2a5a4c0165ace088fab698f689f6d Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 22 Apr 2026 17:08:12 +0100 Subject: [PATCH] test(scope-resolution): rewrite workspace-index test for slimmed index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../scope-resolution/workspace-index.test.ts | 173 ++++++++++++------ 1 file changed, 116 insertions(+), 57 deletions(-) diff --git a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts index e6524de0d..5c5e13815 100644 --- a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts @@ -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'); }); });