From f9ff3904569f2abd19c010568cfca805dc2a9adb Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 20 Apr 2026 08:24:09 +0100 Subject: [PATCH] feat(python-scope): capture function return-type annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/core/ingestion/languages/python/interpret.ts | 1 + .../src/core/ingestion/languages/python/query.ts | 10 ++++++++++ .../src/core/ingestion/languages/python/scopes.scm | 12 ++++++++++++ gitnexus/src/core/ingestion/scope-extractor.ts | 12 +++++++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index a8fa3019e..ef7e7f048 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -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 }; } diff --git a/gitnexus/src/core/ingestion/languages/python/query.ts b/gitnexus/src/core/ingestion/languages/python/query.ts index 4c61b7e28..abfd43766 100644 --- a/gitnexus/src/core/ingestion/languages/python/query.ts +++ b/gitnexus/src/core/ingestion/languages/python/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/python/scopes.scm b/gitnexus/src/core/ingestion/languages/python/scopes.scm index 553e32ddf..c92d0ee67 100644 --- a/gitnexus/src/core/ingestion/languages/python/scopes.scm +++ b/gitnexus/src/core/ingestion/languages/python/scopes.scm @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 172c6f769..071a162d2 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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 = {