feat(csharp-scope): parity Unit 5b — return-type module hoist + chain fallback

Closes 1 parity failure (20 → 19) and lays groundwork for Unit 6.
Based on investigation-agent findings, addresses cluster of 7
cross-file + chain tests whose return-type bindings were stuck at
Class scope and invisible to the chain-follow and propagation passes.

Changes:
- `languages/csharp/simple-hooks.ts::csharpBindingScopeFor`: when the
  declaration is a `@type-binding.return`, hoist the binding all the
  way to the Module scope. The central extractor's auto-hoist only
  promotes one level (Function → Class); for C# methods the parent
  is always a Class, so without this override the return binding
  never reaches Module where chain-follow and cross-file
  `propagateImportedReturnTypes` read from.
- `scope-resolution/passes/compound-receiver.ts`: when the
  class-scope typeBindings lookup at `objClass.typeBindings.get(
  methodName)` misses, walk up from the class scope through the
  parent chain (→ Module) for a return-type binding. Preserves the
  existing class-scope fast-path while restoring owner-chain lookup
  for languages that hoist to Module.

Python parity suite stays 204/204 green on both flag paths;
legacy C# 175/175 green; 19 C# parity failures remain.
This commit is contained in:
Gergo Magyar 2026-04-21 20:25:14 +01:00
parent ea6ee46ce2
commit c27cc0454d
2 changed files with 43 additions and 4 deletions

View file

@ -23,12 +23,31 @@ import type {
* attach to the innermost Namespace scope (which the scope query emits
* for both `namespace X { }` and `namespace X;` forms).
*
* Returns `null` to delegate. */
* Exception: **method return-type bindings** (`@type-binding.return`)
* must hoist all the way to the Module scope. The default auto-hoist
* in the central extractor only promotes one level (Function its
* parent). For C# methods the parent is always a Class, so without
* this override the return binding gets stuck at the Class scope,
* where it's invisible to:
* - chain-follow's parent-chain walk in `followChainPostFinalize`
* (tests: `var u = GetUser(); u.Save()` single-file);
* - cross-file `propagateImportedReturnTypes`, which reads only
* `sourceModule.typeBindings`.
* Walking to Module restores both paths. */
export function csharpBindingScopeFor(
_decl: CaptureMatch,
_innermost: Scope,
_tree: ScopeTree,
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null;
}

View file

@ -107,6 +107,26 @@ export function resolveCompoundReceiverClass(
retType = candidate;
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) {
let curId: ScopeId | null = cs.parent;
while (curId !== null) {
const curScope = scopes.scopeTree.getScope(curId);
if (curScope === undefined) break;
const cand = curScope.typeBindings.get(methodName);
if (cand !== undefined) {
retType = cand;
break;
}
curId = curScope.parent;
}
if (retType !== undefined) break;
}
}
if (retType === undefined && fieldFallback) {