From 9914e3c5da0068206f2b1042a808ffa21862c661 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:43:14 -0700 Subject: [PATCH] fix(lint): disqualify the LIT002 exemption in files with star imports --- scripts/check_type_discipline.py | 17 +++++++++++------ .../test_litellm/test_check_type_discipline.py | 4 ++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index ec7eddf47d6..dfd88d46786 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -30,9 +30,11 @@ LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehens spelling of the `MyTd(...)` call, which was never construction, and an all-ReadOnly payload cannot be grown or rewritten. The exemption is conservative: the TypedDict's name must be bound exactly once in the file - (a name also bound as a function, another class, an assignment target, or - an import alias might resolve to something mutable at the annotation - site), every field it declares or inherits in-module must be + (a name also bound in any other form, another def, an assignment or loop + target, an import alias, a parameter, might resolve to something mutable + at the annotation site, and a `from x import *` anywhere in the file + 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), and a TypedDict imported from another module is out of reach, exactly as in LIT012. Suppress with `# mutable-ok: `. @@ -536,9 +538,10 @@ def _typeddict_assigned_value_ids(tree: ast.AST) -> frozenset[int]: exactly as in LIT012's base-class resolution); 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 - every field it declares or inherits within the module is `ReadOnly[...]`, so - no holder can statically + parameter, could resolve to something mutable at the annotation site, and a + `from x import *` anywhere disqualifies every name in the file, since what it + binds is statically invisible); and every field it declares or inherits + within the module is `ReadOnly[...]`, so no holder can statically rewrite a key even where LIT012 was suppressed or is riding its budget. Only the display itself is exempt; anything mutable nested inside it still trips LIT002. @@ -558,6 +561,8 @@ def _typeddict_assigned_value_ids(tree: ast.AST) -> frozenset[int]: ) bindings = tuple(name for node in ast.walk(tree) for name in _binding_names(node)) + if "*" in bindings: + return frozenset() frozen_names = frozenset( cls.name for cls in classes if bindings.count(cls.name) == 1 and frozen_lineage(cls.name, frozenset()) ) diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index f7ab014dc8b..0cd63fd4fb1 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -250,6 +250,10 @@ 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_star_import_disqualifies_typeddict_exemption(tmp_path): + assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "from elsewhere import *\nx: Td = {'a': 1}\n") + + def test_dict_literal_annotated_as_imported_or_unknown_type_still_counts(tmp_path): assert "LIT002" in _codes(tmp_path, "from other_module import Td\nx: Td = {'a': 1}\n") assert "LIT002" in _codes(tmp_path, "from typing import Final\ny: Final = {'a': 1}\n")