feat(python-scope): capture function return-type annotations

Unit 3 of the python migration architectural plan
(docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md).

Wires the `def get_user() -> User` return-type annotation into the
typeBindings stream so the existing constructor-inferred + transitive
chain machinery can resolve `u = get_user(); u.save()` to `User#save`
without any orchestrator change.

Changes:
- `query.ts` + `scopes.scm`: new `@type-binding.return` pattern keyed by
  the function name (matches RFC §5.1 canonical vocabulary).
- `interpret.ts`: maps `@type-binding.return` to the existing
  `'return-annotation'` source label (no shared change needed).
- `scope-extractor.ts pass4CollectTypeBindings`: extends the Pass 2
  auto-hoist (anchor range == innermost scope range → bind in parent)
  to type bindings as well — return-type bindings whose anchor IS the
  function_definition land in the function's enclosing scope so
  callers see them.

Same-file return-type inference is now end-to-end:
  `def get_user() -> User: ...` + `u = get_user()` produces
  `u: User (return-annotation)` in the caller's scope via
  `followChainedRef`.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 31 fail / 160 pass (no change — every remaining
  return-type test in this fixture set is *cross-file*; carrying
  `get_user → User` across module boundaries lands with the
  cross-file typeBinding propagation work in Unit 5/7).
- tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-20 08:24:09 +01:00
parent 206c18f836
commit f9ff390456
4 changed files with 34 additions and 1 deletions

View file

@ -122,6 +122,7 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}

View file

@ -113,6 +113,16 @@ export const PYTHON_SCOPE_QUERY = `
left: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.annotation
;; Return-type annotation: \`def get_user() -> User:\` binds the
;; FUNCTION'S NAME to its return type in the enclosing scope. Combined
;; with the constructor-inferred + chain-follow path, \`u = get_user()\`
;; then resolves \`u: User\` cross-call. The Python provider hoists the
;; binding via \`pythonBindingScopeFor\` to the function's parent scope
;; so callers in module/class scope see it.
(function_definition
name: (identifier) @type-binding.name
return_type: (type) @type-binding.type) @type-binding.return
;; References calls
(call
function: (identifier) @reference.name) @reference.call.free

View file

@ -125,6 +125,18 @@
left: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.annotation
; ─── Type bindings: function return-type annotations ─────────────────────
;
; `def get_user() -> User:` — binds the function's NAME to its return
; type in the enclosing scope. Combined with the constructor-inferred +
; chain-follow path, `u = get_user()` then resolves `u: User` cross-
; call. Python provider hoists the binding via `pythonBindingScopeFor`
; to the function's parent scope so callers in module/class scope see it.
(function_definition
name: (identifier) @type-binding.name
return_type: (type) @type-binding.type) @type-binding.return
; ─── References: calls ─────────────────────────────────────────────────────
;
; Free call: `print(x)` — function is a bare identifier

View file

@ -666,9 +666,19 @@ function pass4CollectTypeBindings(
const innermost = draftById.get(innermostId);
if (innermost === undefined) continue;
// Auto-hoist for scope-creating type bindings (e.g. Python's
// `@type-binding.return` whose anchor is the function_definition
// itself). Same condition as Pass 2 — when the anchor coincides
// with the innermost scope's range, the binding belongs in the
// enclosing scope (callers, not the function body, look up the
// return type by the function's name).
const autoHostedId =
innermost.parent !== null && rangesEqual(anchor.range, innermost.range)
? innermost.parent
: innermost.id;
// `bindingScopeFor` may hoist the type binding to an outer scope.
const hostId =
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? innermost.id;
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId;
const host = draftById.get(hostId) ?? innermost;
const typeRef: TypeRef = {