refactor(scope-resolution): gate module-scope typeBinding walk-up on hook

Add optional `hoistTypeBindingsToModule` to the ScopeResolver contract
and gate the Module-scope walk-up in `resolveCompoundReceiverClass` on
it. Only providers that hoist method return-type bindings to Module
scope (C#) opt in; Python and other providers no longer traverse that
fallback path.

Closes the architectural leak flagged in the production-readiness
review: the walk-up was unconditional and therefore widened Python's
code path despite existing only for C#.

No behavior change for C# (hook=true restores the prior lookup). No
behavior change for Python (hook undefined = walk-up skipped, matching
pre-PR behavior).

Verified:
  - npx tsc --noEmit           clean
  - C# unit suite              74/74 passing
  - C# + Python integration    388/388 passing
This commit is contained in:
Gergo Magyar 2026-04-22 08:30:03 +01:00
parent 70691378ab
commit 0f18456539
4 changed files with 47 additions and 9 deletions

View file

@ -82,6 +82,12 @@ const csharpScopeResolver: ScopeResolver = {
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
// yield ONE edge, not one per site.
collapseMemberCallsByCallerTarget: true,
// C# hoists method return-type bindings to the enclosing Module
// scope so `propagateImportedReturnTypes` can mirror them across
// files. The compound-receiver walker needs to walk up from the
// class scope to find them; see the contract field for rationale.
hoistTypeBindingsToModule: true,
};
export { csharpScopeResolver };

View file

@ -257,4 +257,22 @@ export interface ScopeResolver {
indexes: ScopeResolutionIndexes,
ctx: { readonly fileContents: ReadonlyMap<string, string> },
) => void;
/**
* Whether the compound-receiver resolver should walk up from a
* class scope to ancestor (Module) scopes when looking up a
* method's return-type typeBinding. Default `false`.
*
* Set `true` when a language stores method return-type bindings at
* Module scope (rather than on each class) so cross-file return-
* type propagation can mirror them. Without this walk-up, chain
* resolution fails for methods whose return types were hoisted to
* module scope.
*
* Languages that attach return-type bindings directly to the class
* scope leave this undefined — enabling the walk-up for them would
* add an unnecessary branch and risk picking up unrelated module-
* level bindings.
*/
readonly hoistTypeBindingsToModule?: boolean;
}

View file

@ -47,6 +47,11 @@ interface ResolveCompoundReceiverOptions {
receiverType: string,
accessor: string,
) => string | undefined;
/** Walk up from the class scope to ancestor (Module) scopes when
* looking up a method's return-type typeBinding. Only enable for
* languages that hoist return-type bindings to Module scope (C#);
* otherwise we risk picking up unrelated module-level bindings. */
readonly hoistTypeBindingsToModule?: boolean;
}
export function resolveCompoundReceiverClass(
@ -116,12 +121,14 @@ export function resolveCompoundReceiverClass(
break;
}
// Fallback: walk up from the class scope looking for a return-
// type binding on an ancestor (Module) scope. Some languages
// (C#) hoist method return-type bindings to Module scope so
// `propagateImportedReturnTypes` can mirror them cross-file;
// this loop restores the owner-chain lookup path for those
// languages without forcing a class-scope copy.
if (cs !== undefined) {
// type binding on an ancestor (Module) scope. Gated on
// `hoistTypeBindingsToModule` because only languages that hoist
// method return-type bindings to Module scope need this path;
// enabling it unconditionally would let other languages pick up
// unrelated module-level bindings. See contract doc for the
// invariant and `propagateImportedReturnTypes` for how the
// hoisted bindings originate.
if (cs !== undefined && options.hoistTypeBindingsToModule === true) {
let curId: ScopeId | null = cs.parent;
while (curId !== null) {
const curScope = scopes.scopeTree.getScope(curId);

View file

@ -56,6 +56,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'fieldFallbackOnMethodLookup'
| 'collapseMemberCallsByCallerTarget'
| 'unwrapCollectionAccessor'
| 'hoistTypeBindingsToModule'
>;
export function emitReceiverBoundCalls(
@ -74,6 +75,12 @@ export function emitReceiverBoundCalls(
const seen = new Set<string>();
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
const collapse = provider.collapseMemberCallsByCallerTarget === true;
const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true;
const compoundOpts = {
fieldFallback,
unwrapCollectionAccessor: provider.unwrapCollectionAccessor,
hoistTypeBindingsToModule,
};
// Build an interface → implementors map from IMPLEMENTS edges.
// Maps Interface graph-id → list of implementor class scope-def-ids.
@ -187,7 +194,7 @@ export function emitReceiverBoundCalls(
site.inScope,
scopes,
index,
{ fieldFallback, unwrapCollectionAccessor: provider.unwrapCollectionAccessor },
compoundOpts,
);
if (currentClass !== undefined) {
const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
@ -314,7 +321,7 @@ export function emitReceiverBoundCalls(
typeRef.declaredAtScope,
scopes,
index,
{ fieldFallback, unwrapCollectionAccessor: provider.unwrapCollectionAccessor },
compoundOpts,
);
if (ownerDef === undefined) {
ownerDef = resolveCompoundReceiverClass(
@ -322,7 +329,7 @@ export function emitReceiverBoundCalls(
typeRef.declaredAtScope,
scopes,
index,
{ fieldFallback, unwrapCollectionAccessor: provider.unwrapCollectionAccessor },
compoundOpts,
);
}
if (ownerDef !== undefined) {