GitNexus/gitnexus/test/integration/hoisted-receiver-review.test.ts
Gergő Magyar cc0b5d54f7
fix(swift,dart): type chained receivers through hoisted return types (#3498) (#3666)
* fix(swift,dart): type chained receivers through hoisted return types (#3498)

Swift and Dart hoist every method's return-type binding out of the class
body onto the Module scope, as C# does, but neither set
hoistTypeBindingsToModule. The receiver walk therefore never looked
there, and svc.getUser().save() had no receiver type. Both now set it.

The flag alone was not safe. The hoisted bindings are keyed by bare
method name, so with class A { getUser() -> User } and
class B { getUser() -> Admin } in one file the Module scope keeps only
B's entry, and a.getUser().save() resolved to Admin.save. C# and
TypeScript already do this on main.

So there is a new ScopeResolver toggle, hoistedTypeBindingsRequireOwner,
which Swift and Dart also set. With it, the walk accepts a hoisted
binding only when its declaration anchor encloses the declaration of a
member of that name owned by the receiver's class. Otherwise it keeps
climbing, and declines if nothing passes. The four walk-ups in
compound-receiver.ts now share one helper.

The toggle is opt-in. With the guard on everywhere, TypeScript loses
correct edges where two same-named methods happen to return the same
type (11 rows in inferred-field-receiver-matrix), because the Module map
cannot hold both. Swift and Dart never had those edges.

Bench: swift plainChain, plainDeepChain, optionalChain and nonNullAssert
(INVISIBLE-GAP) and dart plainChain, plainDeepChain and optionalChain
(VISIBLE-GAP) now RESOLVE with correct targets. No other cell and no
count-arm figure moves. The inferred-field matrix gains correct
Inner.compute edges for Swift (3 rows) and Dart (7 Alien rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): guard hoisted return ownership

* fix(scope): gate hoisted return replay checks

* fix(scope): preserve parameterized return replay

Keep exact callee return facts when ancestor candidates have matching
known parameter types, while refusing same-owner overload ties.

Refs #3666

* fix(scope): preserve known variadic return replay

Compare complete captured parameter vectors across distinct ancestor
owners, including variadic methods with unknown numeric arity. Keep
unknown Swift parameter slots instead of compressing them away.

Refs #3666

* test(swift): retain reviewed receiver fixtures

* fix(scope): retain unknown overload candidates

Keep unknown parameter slots possible without hiding conversion overloads.
Check return-owner precision without requiring valid calls to stay unresolved.

Refs #3498

* fix(scope): preserve conversion suppression

Keep explicit empty conversion results final when parameter metadata is
unknown. Add exact positive controls for receiver-fixture liveness.

Refs #3498

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:11 +00:00

170 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { TransitionalScopeTree } from '../../src/storage/scope-index-store.js';
import {
isLanguageAvailable,
loadParser,
loadLanguage,
} from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
if (dartAvailable) {
try {
await loadParser();
await loadLanguage(SupportedLanguages.Dart);
} catch {
dartAvailable = false;
}
}
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
const swift = `class User { func save() {} }
class Admin { func save() {} }
class Service { func getUser() -> User { return User() } }
func probe(svc: Service) { svc.getUser().save() }
`;
const dart = `class User { void save() {} }
class Admin { void save() {} }
class Service { User getUser() => User(); }
void probe(Service svc) { svc.getUser().save(); }
`;
describe('hoisted receiver review regressions', () => {
for (const [label, file, source, available] of [
['Swift', 'Models.swift', swift, swiftAvailable],
['Dart', 'models.dart', dart, dartAvailable],
] as const) {
it.skipIf(!available)(
`${label} preserves exact edges after the disk seal`,
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-hoisted-disk-'));
const store = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-hoisted-store-'));
const previous = process.env.GITNEXUS_DISK_SCOPE_INDEX;
const seal = vi.spyOn(TransitionalScopeTree.prototype, 'seal');
try {
await fs.writeFile(path.join(root, file), source);
const outputs: string[][] = [];
for (const disk of [false, true]) {
process.env.GITNEXUS_DISK_SCOPE_INDEX = disk ? '1' : '0';
seal.mockClear();
const result = await runPipelineFromRepo(root, () => {}, {
parsedFileStorePath: path.join(store, disk ? 'disk' : 'resident'),
});
expect(seal).toHaveBeenCalledTimes(disk ? 1 : 0);
const calls = [...result.graph.iterRelationships()]
.filter(
(e) =>
e.type === 'CALLS' &&
result.graph.getNode(e.sourceId)?.properties.name === 'probe' &&
result.graph.getNode(e.targetId)?.properties.name === 'save',
)
.map((e) => e.targetId)
.sort();
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('User.save');
outputs.push(calls);
}
expect(outputs[1]).toEqual(outputs[0]);
} finally {
seal.mockRestore();
if (previous === undefined) delete process.env.GITNEXUS_DISK_SCOPE_INDEX;
else process.env.GITNEXUS_DISK_SCOPE_INDEX = previous;
await fs.rm(root, { recursive: true, force: true });
await fs.rm(store, { recursive: true, force: true });
}
},
120000,
);
}
it.skipIf(!swiftAvailable)(
'does not borrow a nearer overload return for an inherited call',
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-swift-inherited-overload-'));
try {
await fs.writeFile(
path.join(root, 'Models.swift'),
`class User { func save() {} }
class Admin { func save() {} }
class Base { func getUser() -> User { return User() } }
class Child: Base { func getUser(_ id: Int) -> Admin { return Admin() } }
func probe(svc: Child) { svc.getUser().save() }
func probeBound(svc: Child) { let user = svc.getUser(); user.save() }
func probeControl(user: User) { user.save() }
`,
);
const result = await runPipelineFromRepo(root, () => {}, {});
// Check owner precision while intermediate argument evidence is limited.
const saveCalls = [...result.graph.iterRelationships()].filter(
(e) =>
e.type === 'CALLS' &&
['probe', 'probeBound'].includes(
result.graph.getNode(e.sourceId)?.properties.name ?? '',
) &&
e.targetId.includes('.save'),
);
for (const call of saveCalls) {
expect(call.targetId).toBe('Function:Models.swift:User.save#0');
}
// This supported call checks fixture-local resolution, not completeness
// of the primary calls or coverage of every selective primary-path loss.
const controlCalls = [...result.graph.iterRelationships()]
.filter(
(e) =>
e.type === 'CALLS' &&
result.graph.getNode(e.sourceId)?.properties.name === 'probeControl' &&
e.targetId.includes('.save'),
)
.map((e) => e.targetId)
.sort();
expect(controlCalls).toEqual(['Function:Models.swift:User.save#0']);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
},
120000,
);
it.skipIf(!dartAvailable)(
'does not confuse a tear-off invocation with its return value call',
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-dart-tearoff-'));
try {
await fs.writeFile(
path.join(root, 'models.dart'),
`class Admin { void save() {} }
class User { Admin call() => Admin(); void save() {} }
class Service { User getUser() => User(); }
void probe(Service svc) { svc.getUser.call().save(); }
void probeControl(User user) { user.save(); }
`,
);
const result = await runPipelineFromRepo(root, () => {}, {});
const saveCalls = [...result.graph.iterRelationships()].filter(
(e) =>
e.type === 'CALLS' &&
result.graph.getNode(e.sourceId)?.properties.name === 'probe' &&
e.targetId.includes('.save'),
);
// Check owner precision; callable-return flow is not modeled yet.
for (const call of saveCalls) {
expect(call.targetId).toBe('Method:models.dart:User.save#0');
}
// This supported call checks fixture-local resolution, not completeness
// of the primary call or coverage of every selective primary-path loss.
const controlCalls = [...result.graph.iterRelationships()]
.filter(
(e) =>
e.type === 'CALLS' &&
result.graph.getNode(e.sourceId)?.properties.name === 'probeControl' &&
e.targetId.includes('.save'),
)
.map((e) => e.targetId)
.sort();
expect(controlCalls).toEqual(['Method:models.dart:User.save#0']);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
},
120000,
);
});