From bbb5c9420d267a22bdec3233a6dba2f6eec96af3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:31:39 -0700 Subject: [PATCH] fix(lint): limit the LIT002 exemption to module-top-level TypedDicts --- scripts/check_type_discipline.py | 15 ++++++++++----- tests/test_litellm/test_check_type_discipline.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index 3ddd84eea20..d3aa2e846a4 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -36,8 +36,9 @@ LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehens disqualifies every name, since what it binds is statically invisible), every field it declares or inherits in-module must be `ReadOnly[...]`, only the display itself is exempt (nested mutables still - count), a TypedDict imported from another module is out of reach, exactly - as in LIT012, and a dotted annotation (`x: mod.Td = {...}`) never + count), a TypedDict imported from another module or nested inside a def + or class is out of reach (only ones defined at the module's top level + resolve file-wide), and a dotted annotation (`x: mod.Td = {...}`) never matches, since it cannot name a local class. Suppress with `# mutable-ok: `. LIT003 noqa suppression without rule codes or without a reason. @@ -541,8 +542,11 @@ def _typeddict_assigned_value_ids(tree: ast.AST) -> frozenset[int]: `x: MyTd = {...}` is the literal spelling of the `MyTd(...)` call, which was never construction to begin with, so the display is a one-shot build -- provided the annotation provably names a frozen payload. Three conditions gate that: - MyTd is a class-form TypedDict in this module (imported ones are invisible, - exactly as in LIT012's base-class resolution); its name is bound exactly once + MyTd is a class-form TypedDict at the module's top level (imported ones are + invisible, exactly as in LIT012's base-class resolution, and one nested in a + def or class is skipped, since its name does not resolve outside the scope + that defines it, while a top-level one resolves everywhere); its name is + bound exactly once in the file (resolution here is scope-blind, so a name the file also binds in any other form, another def, an assignment or loop target, an import alias, a parameter, could resolve to something mutable at the annotation site, and a @@ -553,7 +557,8 @@ def _typeddict_assigned_value_ids(tree: ast.AST) -> frozenset[int]: the display itself is exempt; anything mutable nested inside it still trips LIT002. """ - classes = _typeddict_classes(tree) + top_level_ids = frozenset(id(stmt) for stmt in (tree.body if isinstance(tree, ast.Module) else ())) + classes = tuple(cls for cls in _typeddict_classes(tree) if id(cls) in top_level_ids) by_name = {cls.name: cls for cls in classes} def frozen_lineage(name: str, seen: frozenset[str]) -> bool: diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index 13f386f8e07..5bd6c8e5e65 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -250,6 +250,17 @@ def test_typeddict_name_rebound_elsewhere_gets_no_exemption(tmp_path): assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "def scope(Td):\n return Td\nx: Td = {'a': 1}\n") +def test_nested_typeddict_gets_no_exemption(tmp_path): + assert "LIT002" in _codes( + tmp_path, + "from typing import ReadOnly, TypedDict\n" + "def scope():\n" + " class Td(TypedDict):\n" + " a: ReadOnly[int]\n" + "x: Td = {'a': 1}\n", + ) + + def test_qualified_annotation_gets_no_exemption(tmp_path): assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "import elsewhere\nx: elsewhere.Td = {'a': 1}\n") assert "LIT002" in _codes(