From 8e7f2bf4498496d5f46cc4abdce8457a99bc0682 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:40:45 -0700 Subject: [PATCH] fix(lint): count every binding form in the LIT002 exemption's uniqueness guard --- scripts/check_type_discipline.py | 29 +++++++++++++------ .../test_check_type_discipline.py | 10 +++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index ff748163ecf..ec7eddf47d6 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -504,15 +504,25 @@ def _declared_type_name(annotation: ast.expr) -> str | None: def _binding_names(node: ast.AST) -> tuple[str, ...]: - """The names a single statement binds: class, function, assignment target, import alias.""" + """The names one node binds: def/class/import/parameter/global/except/match forms, + plus any Name stored or deleted, which covers every assignment, loop, walrus, + unpacking, comprehension, and `with` target.""" if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): return (node.name,) if isinstance(node, (ast.Import, ast.ImportFrom)): return tuple(alias.asname or alias.name.partition(".")[0] for alias in node.names) - if isinstance(node, ast.Assign): - return tuple(target.id for target in node.targets if isinstance(target, ast.Name)) - if isinstance(node, (ast.AnnAssign, ast.AugAssign)) and isinstance(node.target, ast.Name): - return (node.target.id,) + if isinstance(node, (ast.Global, ast.Nonlocal)): + return tuple(node.names) + if isinstance(node, ast.arg): + return (node.arg,) + if isinstance(node, ast.ExceptHandler) and node.name is not None: + return (node.name,) + if isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name is not None: + return (node.name,) + if isinstance(node, ast.MatchMapping) and node.rest is not None: + return (node.rest,) + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + return (node.id,) return () @@ -524,10 +534,11 @@ def _typeddict_assigned_value_ids(tree: ast.AST) -> frozenset[int]: 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 - in the file (resolution here is scope-blind, so a name the file also binds as a - function, another class, an assignment target, or an import alias 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 + 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 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. diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index 7b02a4e50da..f7ab014dc8b 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -238,6 +238,16 @@ def test_typeddict_name_rebound_elsewhere_gets_no_exemption(tmp_path): tmp_path, _TYPEDDICT_PREFIX + "def scope():\n Td = dict\n return Td\nx: Td = {'a': 1}\n" ) assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "from elsewhere import Td\nx: Td = {'a': 1}\n") + assert "LIT002" in _codes( + tmp_path, _TYPEDDICT_PREFIX + "def scope(seq):\n for Td in seq:\n pass\nx: Td = {'a': 1}\n" + ) + assert "LIT002" in _codes( + tmp_path, _TYPEDDICT_PREFIX + "def scope(seq):\n Td, other = seq\n return other\nx: Td = {'a': 1}\n" + ) + assert "LIT002" in _codes( + tmp_path, _TYPEDDICT_PREFIX + "def scope(v):\n return (Td := v)\nx: Td = {'a': 1}\n" + ) + assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "def scope(Td):\n return Td\nx: Td = {'a': 1}\n") def test_dict_literal_annotated_as_imported_or_unknown_type_still_counts(tmp_path):