From 0f184565391e8fdf8424808ca75434964082064c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 22 Apr 2026 08:30:03 +0100 Subject: [PATCH] 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 --- .../languages/csharp/scope-resolver.ts | 6 ++++++ .../contract/scope-resolver.ts | 18 ++++++++++++++++++ .../passes/compound-receiver.ts | 19 +++++++++++++------ .../passes/receiver-bound-calls.ts | 13 ++++++++++--- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index e34ef2f01..af9553a4c 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -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 }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index cbd6a850a..8227bb5b0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -257,4 +257,22 @@ export interface ScopeResolver { indexes: ScopeResolutionIndexes, ctx: { readonly fileContents: ReadonlyMap }, ) => 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; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 1927a3d39..3af731bae 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 662388afa..a58ab0669 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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(); 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) {