feat(python): chain type bindings + strip list[T] generic for for-loop

Adds two capture patterns and a shared transitive-closure pass that
together handle Python's variable-aliasing and for-loop-over-typed-
iterable patterns:

1. `(assignment left: (identifier) right: (identifier))` — `alias = u`.
2. `(for_statement left: (identifier) right: (identifier))` — `for u in users`.

Both emit `@type-binding.alias` with the RHS identifier as rawName. The
shared `pass4CollectTypeBindings` now runs a final transitive-closure
walk that follows identifier-chain TypeRefs through the declaring scope
and its ancestors (depth-capped, cycle-guarded) so `alias` ultimately
points at the class type instead of another local variable name.

Generic stripping in `interpret.ts` unwraps single-arg collection
wrappers — `list[User]`, `set[User]`, `Iterable[User]`, etc. — to the
element type. Multi-arg generics (`dict[str, User]`, `Callable[...]`)
are left alone; their semantics aren't unambiguous.

Fixes 8 failures (flag-on 57 → 49):
- Python assignment chain propagation (4)
- Python nullable + assignment chain (2)
- Python walrus operator (:=) assignment chain (2)

Flag-off still 191/191.
This commit is contained in:
Gergo Magyar 2026-04-19 19:46:45 +01:00
parent 989669f6e3
commit 7104b8eac5
3 changed files with 94 additions and 2 deletions

View file

@ -103,8 +103,10 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
// `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()));
// non-nullable ones. Finally strip single-arg generic wrappers so
// `list[User]` / `Iterable[User]` behave like `User` for iterable
// for-loop chain propagation.
const rawType = stripGeneric(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
@ -119,6 +121,7 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
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';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
@ -133,6 +136,24 @@ function stripForwardRefQuotes(text: string): string {
return text;
}
/**
* Unwrap a single-arg generic collection wrapper `list[User]`,
* `set[User]`, `Iterable[User]`, `Sequence[User]`, `Iterator[User]`,
* `Generator[User, ...]` to its element type.
*
* Point: for-loop and cross-file chain propagation need the element
* type, not the container. Multi-arg generics (`dict[str, User]`,
* `Callable[[int], User]`) are left alone the element semantics
* aren't unambiguous and the scope-chain fallback handles them at
* resolution time.
*/
function stripGeneric(text: string): string {
const match = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:list|List|set|Set|tuple|Tuple|Iterable|Iterator|Sequence|Generator|AsyncIterable|AsyncIterator)\[([^,\]]+)\]$/,
);
return match !== null ? match[1].trim() : text;
}
/**
* Unwrap nullable type annotations so downstream resolution treats
* `User | None`, `None | User`, and `Optional[User]` identically to

View file

@ -90,6 +90,24 @@ export const PYTHON_SCOPE_QUERY = `
(dotted_name) @type-binding.type))
(identifier) @type-binding.name) @type-binding.constructor
;; Assignment chain: \`alias = user\` — the new name inherits the
;; RHS-identifier's type. The pattern emits a TypeRef whose rawName is
;; the RHS identifier's text; the scope-extractor's post-pass follows
;; the chain so \`alias\` ends up pointing at whatever type \`user\` has.
(assignment
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; For-loop iterable of an already-typed variable:
;; def f(users: list[User]):
;; for u in users: # u: users (chained via post-pass)
;; u.save()
;; The chain post-pass resolves \`users\` → its own type \`User\` via
;; the generic-arg stripping in \`interpret.ts\`.
(for_statement
left: (identifier) @type-binding.name
right: (identifier) @type-binding.type) @type-binding.alias
;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`)
(assignment
left: (identifier) @type-binding.name

View file

@ -647,6 +647,59 @@ function pass4CollectTypeBindings(
host.typeBindings.set(parsed.boundName, typeRef);
}
}
// ── Transitive closure over identifier-chain type bindings ─────────
// Captures like `(assignment left: (ident) right: (ident))` emit a
// TypeRef whose `rawName` is the RHS identifier. When the RHS name is
// itself a bound variable with a known type in the same scope (or a
// parent scope), follow the chain so `alias` ultimately points at the
// class type — not at another local variable name. Without this,
// `resolveTypeRef` hits the chained name, sees it's a local Variable
// (non-type kind), and strict-returns null.
for (const draft of drafts) {
for (const [name, ref] of draft.typeBindings) {
const resolved = followChainedRef(ref, draftById);
if (resolved !== ref) draft.typeBindings.set(name, resolved);
}
}
}
/** Max chain depth: practical programs rarely exceed 4-5 re-bindings;
* the cap just prevents runaway loops when providers emit cycles. */
const CHAIN_MAX_DEPTH = 16;
/**
* Follow an identifier-chain TypeRef through successive typeBindings
* lookups in the declaring scope and its ancestors. Returns the terminal
* TypeRef (or the original if the chain dead-ends or cycles).
*/
function followChainedRef(start: TypeRef, draftById: ReadonlyMap<ScopeId, ScopeDraft>): TypeRef {
let current = start;
const visited = new Set<string>();
for (let depth = 0; depth < CHAIN_MAX_DEPTH; depth++) {
// A rawName containing a dot (`models.User`) goes through
// `QualifiedNameIndex` at resolution time — don't follow it here.
if (current.rawName.includes('.')) return current;
// Look up the current rawName in the declaring scope and walk up
// the chain until we hit a scope that has a binding for it.
let scopeId: ScopeId | null = current.declaredAtScope;
let next: TypeRef | undefined;
while (scopeId !== null) {
const scope = draftById.get(scopeId);
if (scope === undefined) break;
next = scope.typeBindings.get(current.rawName);
if (next !== undefined) break;
scopeId = scope.parent;
}
if (next === undefined) return current; // dead end — nothing to chain to
if (next === current) return current; // self-ref
if (visited.has(next.rawName)) return current; // cycle guard
visited.add(next.rawName);
current = next;
}
return current;
}
/**