feat(python-scope): dict.items() value-type unwrapping

Two changes that together resolve `for k, v in data.items(): v.save()`:

- `interpret.ts stripGeneric`: extends to `dict[K, V]` /
  `Dict[K, V]` / `Mapping[K, V]` etc., stripping to the value type V.
  Previously only single-arg generics (list[User] → User) were
  stripped; multi-arg ones returned the raw text.
- `query.ts` + `scopes.scm`: new typeBinding patterns for
  `for k, v in X.items()` (both pattern_list and tuple_pattern). The
  second tuple element binds to X; the chain-follow then unwraps X's
  dict annotation to V via the new stripGeneric branch.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 6 fail / 185 pass (was 7/184; +1 — `dict.items() loop`
  test now passes).
- tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-20 12:05:42 +01:00
parent a04cc1732c
commit a446751045
3 changed files with 59 additions and 2 deletions

View file

@ -149,10 +149,22 @@ function stripForwardRefQuotes(text: string): string {
* resolution time.
*/
function stripGeneric(text: string): string {
const match = text.match(
const single = 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;
if (single !== null) return single[1].trim();
// dict[K, V] / Dict[K, V] / Mapping[K, V] — strip to value type V.
// For-loop destructuring of `for k, v in d.items()` binds `v` to
// `d`; the chain-follow then unwraps the dict annotation to V.
// Single-key dict `dict[K]` is not legal Python, so two args is the
// only shape worth handling. Match a top-level K up to the first
// comma and a V to the closing bracket; nested generics in V (e.g.
// `dict[str, list[User]]`) are left for a downstream strip pass.
const dict = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:dict|Dict|Mapping|MutableMapping|OrderedDict|DefaultDict)\[[^,\]]+,\s*([^\]]+)\]$/,
);
if (dict !== null) return dict[1].trim();
return text;
}
/**

View file

@ -144,6 +144,29 @@ export const PYTHON_SCOPE_QUERY = `
(identifier) @type-binding.type))
(#eq? @_enum "enumerate")) @type-binding.alias
;; for k, v in d.items() bind v to d. The chain-follow unwraps d's
;; dict[K, V] annotation to V via the dict-aware stripGeneric in
;; interpret.ts. Covers both pattern_list and tuple_pattern shapes.
(for_statement
left: (pattern_list
(identifier)
(identifier) @type-binding.name)
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))
(#eq? @_items "items")) @type-binding.alias
(for_statement
left: (tuple_pattern
(identifier)
(identifier) @type-binding.name)
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))
(#eq? @_items "items")) @type-binding.alias
;; Type bindings (variable annotations: \`u: User\` / \`u: User = x\`)
(assignment
left: (identifier) @type-binding.name

View file

@ -158,6 +158,28 @@
(identifier) @type-binding.type))
(#eq? @_enum "enumerate")) @type-binding.alias
; for k, v in d.items() — bind v to d. The chain-follow unwraps d's
; dict[K, V] annotation to V via the dict-aware stripGeneric.
(for_statement
left: (pattern_list
(identifier)
(identifier) @type-binding.name)
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))
(#eq? @_items "items")) @type-binding.alias
(for_statement
left: (tuple_pattern
(identifier)
(identifier) @type-binding.name)
right: (call
function: (attribute
object: (identifier) @type-binding.type
attribute: (identifier) @_items))
(#eq? @_items "items")) @type-binding.alias
; ─── Type bindings: function return-type annotations ─────────────────────
;
; `def get_user() -> User:` — binds the function's NAME to its return