feat(python): strip nullable unions + prefer annotations over inference

Two linked changes that together fix the 4 nullable-receiver tests:

1. `stripNullable` in Python's `interpretTypeBinding` unwraps `User | None`,
   `None | User`, and `Optional[User]` to `User`, so receiver-typed
   resolution treats nullable receivers identically to non-nullable ones.
   Three-arm unions (`User | Error | None`) are left unchanged — truly
   ambiguous for single-receiver inference.

2. Source-strength ordering in `pass4CollectTypeBindings`. When multiple
   matches fire for the same bound name in the same scope — e.g. the
   `u: User = find()` idiom where both the annotation and
   constructor-inferred patterns match — the explicit annotation now
   wins regardless of query-match arrival order. Rank:
     explicit (annotation / parameter-annotation / return-annotation / self) > inferred

Also reorders the two Python patterns in query.ts / scopes.scm so the
constructor-inferred pattern appears first — a belt-and-braces fallback
that keeps behavior deterministic if the shared priority ranking is ever
revisited.

Fixes 4 failures (flag-on 63 → 59):
- Python nullable receiver resolution (4 tests)

Flag-off regression check: 191/191 still pass.
This commit is contained in:
Gergo Magyar 2026-04-19 13:20:22 +01:00
parent 2275a1ce97
commit 1a7d1d150f
4 changed files with 96 additions and 24 deletions

View file

@ -100,8 +100,11 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
if (nameCap === undefined || typeCap === undefined) return null;
// Strip surrounding quotes for PEP 484 forward references:
// `def f(x: "User")`.
const rawType = stripForwardRefQuotes(typeCap.text.trim());
// `def f(x: "User")`. Then unwrap nullable unions — `User | None`,
// `None | User`, `Optional[User]` — to the concrete class name so
// receiver-typed resolution treats nullable receivers identically to
// non-nullable ones.
const rawType = stripNullable(stripForwardRefQuotes(typeCap.text.trim()));
// Order matters: more specific anchor captures take precedence. `self`
// and `cls` are synthesized with their own marker captures; the SCM
@ -129,3 +132,28 @@ function stripForwardRefQuotes(text: string): string {
}
return text;
}
/**
* Unwrap nullable type annotations so downstream resolution treats
* `User | None`, `None | User`, and `Optional[User]` identically to
* `User`. A missing/unknown variant returns the input unchanged.
*
* This is a syntactic strip, not a semantic parse it handles the
* canonical PEP-604 and `typing.Optional` shapes that cover the
* overwhelming majority of real-world Python annotations and punts on
* exotic unions (e.g. `User | Error`, which is ambiguous and should not
* auto-bind to one arm).
*/
function stripNullable(text: string): string {
// `Optional[X]` / `typing.Optional[X]` / `t.Optional[X]`
const optMatch = text.match(/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?Optional\[(.+)\]$/);
if (optMatch !== null) return optMatch[1].trim();
// Binary union forms. A three-arm or larger union (`User | None | Error`)
// is ambiguous for single-receiver inference, so we leave it alone.
const parts = text.split('|').map((p) => p.trim());
if (parts.length !== 2) return text;
if (parts[0] === 'None') return parts[1];
if (parts[1] === 'None') return parts[0];
return text;
}

View file

@ -48,17 +48,21 @@ export const PYTHON_SCOPE_QUERY = `
name: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`)
(assignment
left: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.annotation
;; Type bindings (constructor-inferred: \`u = User(...)\`)
;; Listed BEFORE the annotation pattern so \`u: User = find()\` — which
;; matches BOTH patterns has the annotation (later match) overwrite
;; the constructor-inferred guess (earlier match) in the typeBindings
;; map. Explicit user intent beats inference.
(assignment
left: (identifier) @type-binding.name
right: (call
function: (identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`)
(assignment
left: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.annotation
;; References calls
(call
function: (identifier) @reference.name) @reference.call.free

View file

@ -91,6 +91,26 @@
name: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.parameter
; ─── Type bindings: constructor-inferred assignments ───────────────────────
;
; `u = User("alice")` — `u`'s type is inferred from the RHS call's target.
; Python has no `new` keyword, so the pattern matches any `assignment`
; whose RHS is a `call` with a bare-identifier function (constructor-
; shaped). The registry resolves the raw name through the scope chain at
; lookup time, so imported classes, local classes, and aliased imports
; all work without query-time knowledge.
;
; Emits `source: 'constructor-inferred'`.
;
; Listed BEFORE the annotation pattern so `u: User = find()` — which
; matches both patterns — has the annotation (later-processed match)
; overwrite the constructor-inferred guess. Explicit user intent wins.
(assignment
left: (identifier) @type-binding.name
right: (call
function: (identifier) @type-binding.type)) @type-binding.constructor
; ─── Type bindings: variable annotations ───────────────────────────────────
;
; `u: User` or `u: User = some_value` — `u` is explicitly annotated. Both
@ -105,22 +125,6 @@
left: (identifier) @type-binding.name
type: (type) @type-binding.type) @type-binding.annotation
; ─── Type bindings: constructor-inferred assignments ───────────────────────
;
; `u = User("alice")` — `u`'s type is inferred from the RHS call's target.
; Python has no `new` keyword, so the pattern matches any `assignment`
; whose RHS is a `call` with a bare-identifier function (constructor-
; shaped). The registry resolves the raw name through the scope chain at
; lookup time, so imported classes, local classes, and aliased imports
; all work without query-time knowledge.
;
; Emits `source: 'constructor-inferred'`.
(assignment
left: (identifier) @type-binding.name
right: (call
function: (identifier) @type-binding.type)) @type-binding.constructor
; ─── References: calls ─────────────────────────────────────────────────────
;
; Free call: `print(x)` — function is a bare identifier

View file

@ -634,7 +634,43 @@ function pass4CollectTypeBindings(
declaredAtScope: host.id,
source: parsed.source,
};
host.typeBindings.set(parsed.boundName, typeRef);
// Prefer stronger sources when multiple matches fire for the same
// bound name in the same scope. Example: `u: User = find()` matches
// both the annotation and constructor-inferred patterns; the explicit
// annotation (stronger source) must win over the call-site guess
// regardless of query-match arrival order.
const existing = host.typeBindings.get(parsed.boundName);
if (
existing === undefined ||
typeBindingStrength(typeRef.source) >= typeBindingStrength(existing.source)
) {
host.typeBindings.set(parsed.boundName, typeRef);
}
}
}
/**
* Priority ordering when multiple `TypeRef`s compete for the same bound
* name in the same scope. Higher number wins; ties keep the later match
* (last-write-wins preserves historical order within a tier).
*
* Rationale: explicit annotations always beat inferred ones because they
* reflect user intent. `self`/`cls` are treated as strongly as annotations
* because they are language-required receiver types.
*/
function typeBindingStrength(source: TypeRef['source']): number {
switch (source) {
case 'annotation':
case 'parameter-annotation':
case 'return-annotation':
case 'self':
return 2;
case 'assignment-inferred':
case 'constructor-inferred':
case 'receiver-propagated':
return 1;
default:
return 0;
}
}