GitNexus/gitnexus/test/unit/scope-resolution/workspace-index.test.ts
Parafee41 795cf0e151
fix(swift): resolve inherited protocol extension calls (#3309)
* fix(swift): resolve inherited protocol extension calls

* fix(scope): gate inherited implicit receiver lookup

* fix(swift): resolve call result types by exact callee

* test(swift): align cache and local call expectations

* fix(scope): reconcile replay diagnostics

* fix(swift): preserve exact callable return types

* fix(swift): capture throwing async call results

* test(swift): refresh capture golden

* test(swift): refresh scope capture baseline

* fix(swift): require explicit callable returns

* fix(scope): preserve duplicate return metadata

* chore(scope): align index documentation

* Address PR review feedback (#3309)

Stamp only protocol/class-extension members (nested QN + SPM buckets),
arity-narrow implicit-this across MRO, and keep Swift type peeling out
of shared workspace-index via stripTypePreservingDecoration.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3309)

Stamp extension members even when the extension declares a nested type, keep inherited class members ahead of protocol-extension defaults, and report replay-only interface-dispatch fan-out drops.

Note: pre-existing failure in gitnexus tsc against an older gitnexus-shared dist not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3309)

Walk inherited implicit-this owners nearest-first so a nearer override wins, and tighten Swift owner-stamp tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 07:44:35 +01:00

443 lines
15 KiB
TypeScript

/**
* Pin the invariants the workspace-index layer MUST preserve after the
* symbol-indexed duplicates moved to `SemanticModel`.
*
* 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 { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.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(
pythonScopeResolver.languageProvider,
source,
filePath,
() => {},
);
if (parsed === undefined) throw new Error('scope extraction failed');
return parsed;
}
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
function parseSwift(source: string, filePath: string) {
const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {});
if (parsed === undefined) throw new Error('scope extraction failed');
return parsed;
}
describe('WorkspaceResolutionIndex — scope-only maps', () => {
it('exposes classScopeByDefId, classScopeIdToDefId, and moduleScopeByFile', () => {
const parsed = parsePython(
`
class User:
pass
`,
'mod.py',
);
const index = buildWorkspaceResolutionIndex([parsed]);
expect(index.classScopeByDefId).toBeInstanceOf(Map);
expect(index.classScopeIdToDefId).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.skipIf(!swiftAvailable)('declaredReturnTypeByCallableId — exact callable identity', () => {
it('keeps same-file methods and parameter names from overwriting callable returns', () => {
const parsed = parseSwift(
`
struct AResult {}
struct BResult {}
struct OtherStore {}
struct Store {}
struct A {
func make() -> AResult { AResult() }
}
struct B {
func make() -> BResult { BResult() }
}
func makeStore(makeStore: OtherStore) -> Store { Store() }
`,
'Collisions.swift',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const callableDefs = parsed.localDefs.filter((def) => def.returnType !== undefined);
expect(
callableDefs
.map((def) => index.declaredReturnTypeByCallableId.get(def.nodeId)?.rawName)
.sort(),
).toEqual(['AResult', 'BResult', 'Store']);
});
it('keeps extension and decoy return types separate despite the same method name', () => {
const extension = parseSwift(
`
protocol ScenarioSupport {}
struct Store {}
extension ScenarioSupport {
func makeStore() -> Store { Store() }
}
`,
'Support.swift',
);
const decoy = parseSwift(
`
struct OtherStore {}
struct OtherScenario {
private func makeStore() -> OtherStore { OtherStore() }
}
`,
'AUnrelated.swift',
);
const index = buildWorkspaceResolutionIndex([extension, decoy]);
const methods = [...extension.localDefs, ...decoy.localDefs].filter(
(def) => def.qualifiedName?.split('.').at(-1) === 'makeStore',
);
expect(methods).toHaveLength(2);
const byFile = new Map(methods.map((def) => [def.filePath, def]));
expect(
index.declaredReturnTypeByCallableId.get(byFile.get('Support.swift')!.nodeId)?.rawName,
).toBe('Store');
expect(
index.declaredReturnTypeByCallableId.get(byFile.get('AUnrelated.swift')!.nodeId)?.rawName,
).toBe('OtherStore');
expect(index.declaredReturnTypeByCallableId.has('makeStore')).toBe(false);
});
it('does not infer a return type from a same-named parameter', () => {
const parsed = parseSwift(
`
struct OtherStore {}
func makeStore(makeStore: OtherStore) {}
`,
'Unannotated.swift',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const makeStore = parsed.localDefs.find(
(def) => def.qualifiedName?.split('.').at(-1) === 'makeStore',
);
expect(makeStore).toBeDefined();
expect(index.declaredReturnTypeByCallableId.has(makeStore!.nodeId)).toBe(false);
});
it('does not peel array return types without a language stripper', () => {
const parsed = parseSwift(
`
struct User {}
func makeUsers() -> [User] { [] }
`,
'Peel.swift',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const makeUsers = parsed.localDefs.find(
(def) => def.qualifiedName?.split('.').at(-1) === 'makeUsers',
);
expect(makeUsers?.returnType).toBeDefined();
expect(index.declaredReturnTypeByCallableId.get(makeUsers!.nodeId)?.rawName).toBe(
makeUsers!.returnType,
);
});
it('applies stripTypePreservingDecoration to optional returns only', () => {
const parsed = parseSwift(
`
struct Store {}
func makeStore() -> Store? { nil }
func makeUsers() -> [User] { [] }
`,
'Optional.swift',
);
const index = buildWorkspaceResolutionIndex([parsed], undefined, {
stripTypePreservingDecoration: (typeName) =>
typeName.trim().endsWith('?') ? typeName.trim().slice(0, -1).trim() : undefined,
});
const byName = new Map(
parsed.localDefs
.filter((def) => def.returnType !== undefined)
.map((def) => [def.qualifiedName?.split('.').at(-1), def]),
);
expect(index.declaredReturnTypeByCallableId.get(byName.get('makeStore')!.nodeId)?.rawName).toBe(
'Store',
);
expect(index.declaredReturnTypeByCallableId.get(byName.get('makeUsers')!.nodeId)?.rawName).toBe(
byName.get('makeUsers')!.returnType,
);
});
});
describe.skipIf(!swiftAvailable)('Swift call-result assignment extraction', () => {
it('aligns each lhs with its own same-name call-resolution anchor', () => {
const parsed = parseSwift(
`
func run() {
let store = makeStore()
let other = makeStore()
}
`,
'Scenario.swift',
);
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments.map(({ lhs }) => lhs)).toEqual(['store', 'other']);
expect(
new Set(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).size,
).toBe(2);
const callAnchors = parsed.referenceSites
.filter((site) => site.name === 'makeStore')
.map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`);
expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual(
callAnchors,
);
expect(new Set(assignments.map(({ inScope }) => inScope)).size).toBe(1);
});
it('does not emit replay facts for explicitly typed declarations', () => {
const parsed = parseSwift(
`
func run() {
let explicit: Store = makeStore()
}
`,
'Typed.swift',
);
expect(parsed.callResultAssignmentSites).toBeUndefined();
});
it('unwraps await and try expressions to the exact call position', () => {
const parsed = parseSwift(
`
func run() async throws {
let awaited = await makeStore()
let tried = try makeStore()
let triedAwaited = try await makeStore()
}
`,
'Wrapped.swift',
);
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments.map(({ lhs }) => lhs)).toEqual(['awaited', 'tried', 'triedAwaited']);
const callAnchors = parsed.referenceSites
.filter((site) => site.name === 'makeStore')
.map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`);
expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual(
callAnchors,
);
});
});
describe('findExportedDef — module-export visibility filter', () => {
it('keeps top-level class and function defs', () => {
const parsed = parsePython(
`
class User:
def save(self) -> bool:
return True
def helper() -> int:
return 42
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
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 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:
MAX_USERS = 100
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
expect(findExportedDef('mod.py', 'MAX_USERS', index)).toBeUndefined();
// Positive-case invariant: the Class def itself is still exported.
expect(findExportedDef('mod.py', 'User', index)?.type).toBe('Class');
});
it('excludes class methods from module-export lookup', () => {
const parsed = parsePython(
`
class User:
def save(self) -> bool:
return True
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
// `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:
def save(self) -> bool:
return True
def save(x: int) -> int:
return x
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
const finalized = finalizeScopeModel([parsed]);
const index = buildWorkspaceResolutionIndex([parsed]);
// 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:
def save(self) -> bool:
return True
`,
'mod.py',
);
pythonScopeResolver.populateOwners(parsed);
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 found = findOwnedMember(classDef!.nodeId, 'save', model);
expect(found?.type).toBe('Method');
expect(found?.qualifiedName).toBe('User.save');
});
});
describe('classScopeIdToDefId — inverse-map invariant', () => {
it('classScopeIdToDefId is populated in sync with classScopeByDefId and is an exact inverse', () => {
const parsed = parsePython(
`
class User:
def save(self) -> bool:
return True
class Admin:
def promote(self) -> None:
pass
`,
'mod.py',
);
const index = buildWorkspaceResolutionIndex([parsed]);
// Same size — the two maps are populated in lockstep.
expect(index.classScopeIdToDefId.size).toBe(index.classScopeByDefId.size);
expect(index.classScopeIdToDefId.size).toBe(2);
// Forward → reverse round-trip.
for (const [defId, scope] of index.classScopeByDefId) {
expect(index.classScopeIdToDefId.get(scope.id)).toBe(defId);
}
// Reverse → forward round-trip.
for (const [scopeId, defId] of index.classScopeIdToDefId) {
const scope = index.classScopeByDefId.get(defId);
expect(scope).toBeDefined();
expect(scope!.id).toBe(scopeId);
}
});
it('classScopeIdToDefId is empty for a file with no classes', () => {
const parsed = parsePython(
`
def helper() -> int:
return 42
`,
'mod.py',
);
const index = buildWorkspaceResolutionIndex([parsed]);
expect(index.classScopeIdToDefId.size).toBe(0);
});
});