sarj-python-lint 0.13.1__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_registry.py +6 -0
  4. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/no_file_level_suppression.py +228 -0
  5. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +202 -0
  6. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/prefer_module_level_constant.py +639 -0
  7. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/.gitignore +0 -0
  8. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/README.md +0 -0
  9. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_secret_names.py +0 -0
  12. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_version.py +0 -0
  13. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/py.typed +0 -0
  14. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rule_base.py +0 -0
  15. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  16. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  17. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  18. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  19. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  20. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  21. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  22. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  23. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  24. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  25. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  26. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  27. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  28. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  29. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  30. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  31. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  32. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  33. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  34. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  35. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  36. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  37. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  38. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  39. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  40. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  41. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  42. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  43. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  44. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  45. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  46. {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.13.1
3
+ Version: 0.14.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.13.1"
3
+ version = "0.14.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -14,6 +14,7 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
14
14
  NoCorsWildcardWithCredentials,
15
15
  )
16
16
  from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
17
+ from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
17
18
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
18
19
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
19
20
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
@@ -33,6 +34,9 @@ from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
33
34
  PreferConstantTimeSecretCompare,
34
35
  )
35
36
  from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
37
+ from sarj_python_lint.rules.prefer_module_level_constant import (
38
+ PreferModuleLevelConstant,
39
+ )
36
40
  from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
37
41
  PreferNamedtupleOverTupleReturn,
38
42
  )
@@ -92,6 +96,8 @@ REGISTRY: dict[str, type[Rule]] = {
92
96
  PreferMatchAssertNever.id: PreferMatchAssertNever,
93
97
  KwonlySameTypeParams.id: KwonlySameTypeParams,
94
98
  NoRawSqlInTests.id: NoRawSqlInTests,
99
+ NoFileLevelSuppression.id: NoFileLevelSuppression,
100
+ PreferModuleLevelConstant.id: PreferModuleLevelConstant,
95
101
  }
96
102
 
97
103
  __all__ = ["REGISTRY"]
@@ -0,0 +1,228 @@
1
+ """SARJ038: module-scope unscoped suppression blanket — scope it or fix the findings.
2
+
3
+ A per-line suppression is a claim about one line. A module-scope *unscoped*
4
+ blanket is a claim about every line in the file, forever: it switches the whole
5
+ checker off for the file, including the rules that do not exist yet. The next
6
+ person adds a function to the file and their new violations are pre-silenced by
7
+ a decision someone else made months ago, in a comment they will never scroll
8
+ past. Nothing in review or CI says a word. This is the file-level-suppression
9
+ escape hatch raised in review (`sarj-ai/bulbul#2881`).
10
+
11
+ A SCOPED suppression is the opposite: naming the codes it silences makes it a
12
+ reviewed, legible, bounded decision, and it keeps working exactly as intended
13
+ when new rules land. Scoped forms are NEVER flagged by this rule.
14
+
15
+ Fires on exactly three shapes:
16
+
17
+ 1. Bare `# ruff: noqa` — anywhere in the file, since ruff honours the
18
+ file-level exemption wherever the comment appears. A trailing prose reason
19
+ (`# ruff: noqa — legacy module`) is still unscoped: it names no codes.
20
+ 2. Standalone `# type: ignore` (mypy) appearing BEFORE the module's first
21
+ statement — that position is what makes it file-level rather than per-line.
22
+ 3. Standalone `# pyright: ignore` before the module's first statement.
23
+
24
+ "Standalone" means the comment is the only thing on its line. "Before the first
25
+ statement" means above the line of the first token that is not a comment or
26
+ layout — a module docstring IS a statement, so a `# type: ignore` under the
27
+ docstring is not file-level.
28
+
29
+ Deliberately NOT flagged:
30
+
31
+ * every scoped counterpart — `# ruff: noqa: E501`, `# ruff: noqa: E501, F401`,
32
+ `# type: ignore[attr-defined]`, `# pyright: ignore[reportUnusedImport]`;
33
+ * trailing per-line suppressions — `x = foo() # type: ignore`,
34
+ `y = bar() # pyright: ignore` — these bind to one line by construction, and
35
+ their position in the file is irrelevant;
36
+ * a bare per-line `# noqa` with no `ruff:` prefix: it silences one line, not the
37
+ file, and belongs to a different rule;
38
+ * other `ruff:` directives that are not `noqa` (`# ruff:ignore[...]`), and
39
+ pyright's configuration comments (`# pyright: strict`);
40
+ * shebangs, encoding cookies (`# -*- coding: utf-8 -*-`) and license headers,
41
+ which legitimately precede the first statement.
42
+
43
+ The rule is token-based rather than AST-based because comments do not survive
44
+ `ast.parse`. Malformed input yields no diagnostics rather than an exception.
45
+
46
+ A file-level blanket that is genuinely justified (vendored code, a generated
47
+ module) is suppressed with `# sarj-noqa: SARJ038 — <reason>`, which puts the
48
+ reason in the diff where a reviewer sees it.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from dataclasses import dataclass, replace
54
+ import io
55
+ import re
56
+ import tokenize
57
+ from typing import TYPE_CHECKING, override
58
+
59
+ from sarj_python_lint.rule_base import Diagnostic, Rule
60
+
61
+
62
+ if TYPE_CHECKING:
63
+ from pathlib import Path
64
+
65
+
66
+ # Sentinel row for "this file has no statement at all" (empty / comments-only):
67
+ # every comment then counts as preceding the first statement.
68
+ _NO_STATEMENT_LINE = 1 << 30
69
+
70
+ # Directive heads, spelled as the tools themselves accept them: no space before
71
+ # the colon, optional space after. `ruff:` is matched case-insensitively (ruff
72
+ # accepts `NOQA`); mypy and pyright only honour their directives in lowercase.
73
+ _RUFF_NOQA_RE = re.compile(r"^ruff:\s*noqa(?P<rest>.*)", re.IGNORECASE)
74
+ _TYPE_IGNORE_RE = re.compile(r"^type:\s*ignore(?P<rest>.*)")
75
+ _PYRIGHT_IGNORE_RE = re.compile(r"^pyright:\s*ignore(?P<rest>.*)")
76
+
77
+ # What a SCOPED suppression looks like after the directive head: ruff lists its
78
+ # codes after a colon, mypy and pyright inside brackets. At least one word
79
+ # character is required, since a trailing bare colon names no codes either.
80
+ _RUFF_CODES_RE = re.compile(r"^\s*:\s*\w")
81
+ _BRACKET_CODES_RE = re.compile(r"^\s*\[\s*\w")
82
+
83
+ # `type: ignored` / `ruff: noqas` are different words, not the directive.
84
+ _WORD_CONTINUATION_RE = re.compile(r"^\w")
85
+
86
+ _LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
87
+ _NON_STATEMENT_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
88
+
89
+ _RUFF_MESSAGE = (
90
+ "bare `# ruff: noqa` exempts this entire file from every ruff rule, "
91
+ "including ones added later — scope it (`# ruff: noqa: E501`) or fix the findings."
92
+ )
93
+ _TYPE_IGNORE_MESSAGE = (
94
+ "file-level `# type: ignore` silences every mypy error in this file, "
95
+ "including ones added later — scope it (`# type: ignore[attr-defined]`) "
96
+ "or fix the findings."
97
+ )
98
+ _PYRIGHT_IGNORE_MESSAGE = (
99
+ "file-level `# pyright: ignore` silences every pyright diagnostic in this file, "
100
+ "including ones added later — scope it (`# pyright: ignore[reportUnusedImport]`) "
101
+ "or fix the findings."
102
+ )
103
+
104
+
105
+ class NoFileLevelSuppression(Rule):
106
+ """Module-scope unscoped suppression blanket — scope it to the codes it silences."""
107
+
108
+ id: str = "no-file-level-suppression"
109
+ code: str = "SARJ038"
110
+ description: str = (
111
+ "An unscoped file-level suppression (`# ruff: noqa`, `# type: ignore`, "
112
+ "`# pyright: ignore`) switches a whole checker off for the file, "
113
+ "including rules added later — scope it to the codes it silences."
114
+ )
115
+
116
+ @override
117
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
118
+ """Report every unscoped module-scope suppression blanket in `source`.
119
+
120
+ Input that cannot be lexed (unterminated string, bad indentation)
121
+ yields no diagnostics rather than an exception.
122
+
123
+ Returns:
124
+ The diagnostics, sorted by (line, col).
125
+
126
+ """
127
+ try:
128
+ comments = _scan_comments(source)
129
+ except tokenize.TokenError, IndentationError, SyntaxError:
130
+ return []
131
+ diags = [
132
+ Diagnostic(path=path, line=comment.line, col=comment.col, code=self.code, message=message)
133
+ for comment in comments
134
+ if (message := _blanket_message(comment)) is not None
135
+ ]
136
+ diags.sort(key=lambda d: (d.line, d.col))
137
+ return diags
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class _Comment:
142
+ """One comment token, with the context that decides whether it is file-level."""
143
+
144
+ line: int
145
+ col: int
146
+ body: str
147
+ standalone: bool
148
+ before_first_statement: bool = False
149
+
150
+
151
+ def _blanket_message(comment: _Comment) -> str | None:
152
+ """Classify a comment as one of the three unscoped blankets.
153
+
154
+ A bare `# ruff: noqa` counts wherever it sits; the mypy and pyright forms
155
+ only count when they stand alone above the module's first statement, since
156
+ anywhere else they are per-line suppressions.
157
+
158
+ Returns:
159
+ The diagnostic message, or None when the comment is not a blanket.
160
+
161
+ """
162
+ if _is_unscoped(comment.body, _RUFF_NOQA_RE, _RUFF_CODES_RE):
163
+ return _RUFF_MESSAGE
164
+ if not (comment.standalone and comment.before_first_statement):
165
+ return None
166
+ if _is_unscoped(comment.body, _TYPE_IGNORE_RE, _BRACKET_CODES_RE):
167
+ return _TYPE_IGNORE_MESSAGE
168
+ if _is_unscoped(comment.body, _PYRIGHT_IGNORE_RE, _BRACKET_CODES_RE):
169
+ return _PYRIGHT_IGNORE_MESSAGE
170
+ return None
171
+
172
+
173
+ def _is_unscoped(body: str, directive: re.Pattern[str], codes: re.Pattern[str]) -> bool:
174
+ """Report whether `body` is `directive` with no code list after it.
175
+
176
+ Returns:
177
+ True when the directive matches and names no codes.
178
+
179
+ """
180
+ match = directive.match(body)
181
+ if match is None:
182
+ return False
183
+ rest = match["rest"]
184
+ if _WORD_CONTINUATION_RE.match(rest):
185
+ return False
186
+ return not codes.match(rest)
187
+
188
+
189
+ def _scan_comments(source: str) -> list[_Comment]:
190
+ """Tokenize `source` and describe every comment in it.
191
+
192
+ A comment is standalone when no token ended on its line before it, and
193
+ precedes the first statement when it sits above the first non-comment,
194
+ non-layout token.
195
+
196
+ Returns:
197
+ Every comment, in source order.
198
+
199
+ """
200
+ comments: list[_Comment] = []
201
+ first_statement_line = _NO_STATEMENT_LINE
202
+ prev_end_row = 0
203
+ readline = io.StringIO(source).readline
204
+ for tok in tokenize.generate_tokens(readline):
205
+ if tok.type == tokenize.COMMENT:
206
+ comments.append(
207
+ _Comment(
208
+ line=tok.start[0],
209
+ col=tok.start[1] + 1,
210
+ body=_comment_body(tok.string),
211
+ standalone=tok.start[0] != prev_end_row,
212
+ )
213
+ )
214
+ if tok.type not in _LAYOUT_TOKENS:
215
+ prev_end_row = tok.end[0]
216
+ if tok.type not in _NON_STATEMENT_TOKENS:
217
+ first_statement_line = min(first_statement_line, tok.start[0])
218
+ return [replace(c, before_first_statement=c.line < first_statement_line) for c in comments]
219
+
220
+
221
+ def _comment_body(raw: str) -> str:
222
+ """Strip a comment token down to its directive text.
223
+
224
+ Returns:
225
+ The comment text without its leading `#` markers or surrounding space.
226
+
227
+ """
228
+ return raw.lstrip("#").strip()
@@ -79,6 +79,32 @@ payload shapes dominate and are deliberate fall-throughs, not bugs):
79
79
  else:
80
80
  return None # new Status member silently ignored
81
81
 
82
+ 3. **Handler dict that does not cover the enum.** A `dict` literal whose keys
83
+ are all members of ONE enum defined at module scope of this module, whose
84
+ values all look like handlers (a name, an attribute, or a lambda — never a
85
+ literal), and which names FEWER members than the enum declares. This is the
86
+ lookup-table spelling of the same bug: `HANDLERS[kind]` raises `KeyError` on
87
+ the missed member if you are lucky and `HANDLERS.get(kind)` silently returns
88
+ `None` if you are not, and neither pyright nor a `match` statement is there
89
+ to notice. The message names the shortfall so the missing members are
90
+ obvious.
91
+
92
+ # flagged — Kind has three members, the map has two
93
+ class Kind(StrEnum):
94
+ A = "a"
95
+ B = "b"
96
+ C = "c"
97
+
98
+ HANDLERS = {Kind.A: handle_a, Kind.B: handle_b}
99
+
100
+ Deliberately NOT flagged here: a map whose values are literals (that is a
101
+ lookup table of data — a partial one is routinely intentional, e.g. "only
102
+ these two members have a display colour"); a map that spreads `**other`
103
+ (its real key set is not visible); a map whose name is later `.update(...)`d
104
+ or assigned into by subscript anywhere in the file (it is built in pieces on
105
+ purpose); and any map over an imported enum, since the rule cannot see how
106
+ many members that enum has and guessing would invent findings.
107
+
82
108
  A deliberate ignore-the-rest dispatch (e.g. classifying external ids where the
83
109
  provider can add values at any time) is suppressed with
84
110
  `# sarj-noqa: SARJ032 — <reason>`.
@@ -106,6 +132,9 @@ _MIN_ARMS = 2
106
132
 
107
133
  _ENUM_BASES = frozenset({"Enum", "StrEnum", "IntEnum", "Flag", "IntFlag", "ReprEnum"})
108
134
 
135
+ # Methods that grow a dict in place — a map built in pieces is not incomplete.
136
+ _DICT_GROWING_METHODS = frozenset({"update", "setdefault"})
137
+
109
138
 
110
139
  class PreferMatchAssertNever(Rule):
111
140
  """Silent fallthrough on closed-set dispatch — raise or `assert_never` instead."""
@@ -134,6 +163,8 @@ class PreferMatchAssertNever(Rule):
134
163
  )
135
164
  )
136
165
  member_owners = local_classes | _importfrom_bound_names(tree)
166
+ enum_members = _enum_member_names(module_classdefs, local_enums)
167
+ grown_maps = _grown_dict_names(tree)
137
168
  diags: list[Diagnostic] = []
138
169
  consumed_elifs: set[int] = set()
139
170
  for node in ast.walk(tree):
@@ -170,6 +201,24 @@ class PreferMatchAssertNever(Rule):
170
201
  ),
171
202
  )
172
203
  )
204
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
205
+ shortfall = _incomplete_dispatch_map(node, enum_members, grown_maps)
206
+ if shortfall is not None:
207
+ enum_name, covered, total, missing = shortfall
208
+ diags.append(
209
+ Diagnostic(
210
+ path=path,
211
+ line=node.lineno,
212
+ col=node.col_offset + 1,
213
+ code=self.code,
214
+ message=(
215
+ f"dispatch map covers {covered} of `{enum_name}`'s {total} "
216
+ f"members (missing {missing}) — a new member falls through to "
217
+ "a KeyError or a silent None; cover every member and "
218
+ "`assert_never` (or raise) on a lookup miss."
219
+ ),
220
+ )
221
+ )
173
222
  diags.sort(key=lambda d: (d.line, d.col))
174
223
  return diags
175
224
 
@@ -195,6 +244,159 @@ def _module_scope_classdefs(tree: ast.Module) -> list[ast.ClassDef]:
195
244
  return found
196
245
 
197
246
 
247
+ def _enum_member_names(
248
+ classdefs: list[ast.ClassDef], local_enums: frozenset[str]
249
+ ) -> dict[str, frozenset[str]]:
250
+ """Map each module-scope enum's name to the member names it declares.
251
+
252
+ A member is a plain `NAME = <value>` assignment in the class body. Methods,
253
+ annotations without a value (`x: int`), and private/dunder names are not
254
+ members. Members sharing one constant value are ALIASES of a single member
255
+ (`AKA = "open"` beside `OPEN = "open"`), so they are counted once —
256
+ over-counting members would invent a shortfall that does not exist.
257
+
258
+ Returns:
259
+ A mapping of enum class name to its member-name set.
260
+
261
+ """
262
+ members: dict[str, frozenset[str]] = {}
263
+ for classdef in classdefs:
264
+ if classdef.name not in local_enums:
265
+ continue
266
+ by_value: dict[str, str] = {}
267
+ names: set[str] = set()
268
+ for stmt in classdef.body:
269
+ if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
270
+ continue
271
+ target = stmt.targets[0]
272
+ if not isinstance(target, ast.Name) or target.id.startswith("_"):
273
+ continue
274
+ if isinstance(stmt.value, ast.Constant):
275
+ key = f"{type(stmt.value.value).__name__}:{stmt.value.value!r}"
276
+ if key in by_value:
277
+ # An alias of an already-counted member.
278
+ continue
279
+ by_value[key] = target.id
280
+ names.add(target.id)
281
+ members[classdef.name] = frozenset(names)
282
+ return members
283
+
284
+
285
+ def _grown_dict_names(tree: ast.Module) -> frozenset[str]:
286
+ """Collect names of dicts that are grown after their literal is written.
287
+
288
+ `HANDLERS.update(...)`, `HANDLERS.setdefault(k, v)` and `HANDLERS[k] = v`
289
+ all mean the literal is a starting point rather than the whole map, so its
290
+ key count says nothing about coverage.
291
+
292
+ Returns:
293
+ The set of names that are extended somewhere in the module.
294
+
295
+ """
296
+ grown: set[str] = set()
297
+ for node in ast.walk(tree):
298
+ match node:
299
+ case ast.Call(
300
+ func=ast.Attribute(value=ast.Name(id=name), attr=attr)
301
+ ) if attr in _DICT_GROWING_METHODS:
302
+ grown.add(name)
303
+ case ast.Assign(targets=targets):
304
+ grown.update(
305
+ subscript.value.id
306
+ for subscript in targets
307
+ if isinstance(subscript, ast.Subscript)
308
+ and isinstance(subscript.value, ast.Name)
309
+ )
310
+ case _:
311
+ pass
312
+ return frozenset(grown)
313
+
314
+
315
+ def _incomplete_dispatch_map(
316
+ node: ast.Assign | ast.AnnAssign,
317
+ enum_members: dict[str, frozenset[str]],
318
+ grown_maps: frozenset[str],
319
+ ) -> tuple[str, int, int, str] | None:
320
+ """Return the shortfall when `node` binds a handler dict that misses enum members.
321
+
322
+ Requires a single `Name` target, a `dict` literal value with no `**` spread,
323
+ at least `_MIN_ARMS` keys that are ALL members of one module-scope enum, all
324
+ values handler-shaped (name / attribute / lambda), and a name that is never
325
+ grown elsewhere in the module.
326
+
327
+ Returns:
328
+ `(enum name, covered, total, missing members)`, or None when the
329
+ assignment is not an incomplete dispatch map.
330
+
331
+ """
332
+ target = _single_name_target(node)
333
+ if target is None or target in grown_maps or not isinstance(node.value, ast.Dict):
334
+ return None
335
+ mapping = node.value
336
+ if any(key is None for key in mapping.keys):
337
+ # `**other` — the real key set is not visible here.
338
+ return None
339
+ if len(mapping.keys) < _MIN_ARMS:
340
+ return None
341
+ if not all(_is_handler_value(value) for value in mapping.values):
342
+ return None
343
+ owners = {_member_owner(key) for key in mapping.keys}
344
+ if len(owners) != 1:
345
+ return None
346
+ owner = next(iter(owners))
347
+ if owner is None or owner not in enum_members:
348
+ return None
349
+ declared = enum_members[owner]
350
+ covered = {
351
+ key.attr for key in mapping.keys if isinstance(key, ast.Attribute) and key.attr in declared
352
+ }
353
+ if len(covered) != len(mapping.keys) or not covered < declared:
354
+ return None
355
+ missing = ", ".join(f"{owner}.{name}" for name in sorted(declared - covered))
356
+ return owner, len(covered), len(declared), missing
357
+
358
+
359
+ def _single_name_target(node: ast.Assign | ast.AnnAssign) -> str | None:
360
+ """Return the bound name when `node` assigns to exactly one plain name.
361
+
362
+ Returns:
363
+ The target name, or None for tuple/attribute/subscript targets.
364
+
365
+ """
366
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
367
+ if len(targets) != 1:
368
+ return None
369
+ target = targets[0]
370
+ return target.id if isinstance(target, ast.Name) else None
371
+
372
+
373
+ def _is_handler_value(value: ast.expr | None) -> bool:
374
+ """Report whether a dict value looks like a handler rather than data.
375
+
376
+ A literal value makes the dict a data table, where a partial mapping is
377
+ routinely deliberate; a name, attribute or lambda makes it a dispatch table.
378
+
379
+ Returns:
380
+ True when the value is handler-shaped.
381
+
382
+ """
383
+ return isinstance(value, (ast.Name, ast.Attribute, ast.Lambda))
384
+
385
+
386
+ def _member_owner(key: ast.expr | None) -> str | None:
387
+ """Return the class name in a `Owner.MEMBER` dict key.
388
+
389
+ Returns:
390
+ The owner name, or None when the key is not simple member access.
391
+
392
+ """
393
+ match key:
394
+ case ast.Attribute(value=ast.Name(id=owner)):
395
+ return owner
396
+ case _:
397
+ return None
398
+
399
+
198
400
  def _importfrom_bound_names(tree: ast.Module) -> frozenset[str]:
199
401
  """Collect names bound by `from x import Name [as Alias]` statements.
200
402
 
@@ -0,0 +1,639 @@
1
+ """SARJ039: literal-only constant collection built inside a function — hoist it.
2
+
3
+ Python port of the TypeScript rule `prefer-module-level-constant`. A lookup
4
+ table, allow-list, membership `frozenset` or validation regex written at the top
5
+ of a function is rebuilt on every single call. Three costs, in increasing order
6
+ of severity:
7
+
8
+ 1. Allocation — every call re-walks the display and re-allocates the list /
9
+ dict / set / tuple, and `re.compile` re-parses the pattern (the module-level
10
+ `re` cache is bounded and evicted wholesale, so it is not a substitute).
11
+ 2. Discoverability — a domain constant buried in a function body is invisible
12
+ to the next reader and cannot be imported, tested, or reused, so it gets
13
+ duplicated in the next function that needs it. That is the defect class: the
14
+ duplicated copies drift apart.
15
+ 3. Review churn — this was the single most frequent recurring review comment in
16
+ the mined PR corpus, in Python and TypeScript alike.
17
+
18
+ Fires on a local binding (`x = ...` / `x: T = ...`) inside a `def` / `async def`
19
+ whose value is one of:
20
+
21
+ * a list / dict / set / tuple display, or `frozenset([...])`, with at least
22
+ `_MIN_ELEMENTS` top-level entries and where EVERY leaf (dict values and keys
23
+ included) is an `ast.Constant` — or a signed numeric constant, or a nested
24
+ display of the same, up to `_MAX_LITERAL_DEPTH`; or
25
+ * `re.compile("<literal>")`, optionally with constant flags
26
+ (`re.I`, `re.I | re.M`, a plain int).
27
+
28
+ "Every leaf is a constant" is the load-bearing gate, not a stylistic
29
+ preference. A `Name`, `Attribute`, `Call`, comprehension or f-string leaf means
30
+ the value can capture a parameter or observe call-time state, so hoisting it
31
+ would be a `NameError` or a behaviour change. Gating on constants kills that
32
+ entire false-positive class — including the common
33
+ `allowed = [user_id, "admin"]` shape — outright.
34
+
35
+ Escape / mutation analysis is deliberately STRICT, and stricter than the
36
+ TypeScript original. A module-level Python constant is import-time-shared
37
+ mutable state living for the life of the process: a wrong hoist is a
38
+ cross-request data-corruption bug, not a style regression. So the rule bails
39
+ unless EVERY reference to the binding inside the enclosing function is a
40
+ provably non-mutating, non-escaping read.
41
+
42
+ Deliberately NOT flagged:
43
+
44
+ * **Rebound names.** The name must be bound exactly ONCE in the function. Any
45
+ second binding bails: re-assignment, `x += [...]`, a walrus rebind, `del x`,
46
+ a `global` / `nonlocal` declaration, a `for` target, `with ... as x`,
47
+ `except ... as x`, a comprehension or `match` capture target, an `import as`,
48
+ a same-named nested `def` / `class`, or a parameter of the same name.
49
+ * **Mutated collections.** Any method call on the binding that is not on the
50
+ explicit safe list (`get`, `keys`, `values`, `items`, `copy`, `index`,
51
+ `count`) is treated as mutating — default-DENY, so `.append` / `.extend` /
52
+ `.insert` / `.remove` / `.pop` / `.clear` / `.sort` / `.reverse` / `.update` /
53
+ `.add` / `.discard` / `.setdefault` / `.popitem` / `.__setitem__` are covered
54
+ along with anything a future stdlib grows. A subscript STORE (`x[k] = v`),
55
+ `del x[k]`, or an attribute store (`x.attr = v`) likewise bails. A compiled
56
+ regex gets its own safe-method list (`match`, `search`, `fullmatch`,
57
+ `findall`, `finditer`, `split`, `sub`, `subn`) since `re.Pattern` is immutable.
58
+ * **Escaping values the caller may mutate.** `return x`, `yield x`, passing `x`
59
+ bare as a positional or keyword argument to a call that is not a known
60
+ non-retaining consumer, `self.x = x`, `d[k] = x`, embedding it in a
61
+ `[x]` / `{...}` display, aliasing it (`y = x`), a `*x` / `**x` spread, or
62
+ capturing it in a nested `def` / `lambda` / `class` body. Once the value
63
+ leaves the function this rule cannot see what happens to it.
64
+ * **Safe consumers still fire** — `len(x)`, `sorted(x)`, `set`/`frozenset`/
65
+ `list`/`tuple`/`dict`, `any`, `all`, `min`, `max`, `sum`, `enumerate`,
66
+ `reversed`, `iter`, `json.dumps`, the non-mutating methods above, `x` as a
67
+ `for`/comprehension iterable, `k in x`, a subscript LOAD (`x[0]`), a
68
+ comparison, and f-string interpolation. Note `sorted(x)` and `list(x)` COPY,
69
+ so they are reads, while `x.sort()` mutates in place and bails.
70
+ * **Tiny displays** (`< _MIN_ELEMENTS` entries) read better next to their use.
71
+ * **Test files and generated files** (`_paths.is_test_path` /
72
+ `_paths.is_generated_source`): fixture tables belong next to the assertion
73
+ that explains them, and generated code mirrors its generator.
74
+
75
+ One real difference from the TypeScript original: that rule must never hoist a
76
+ `/g` or `/y` regex, because a JavaScript RegExp object carries `lastIndex`
77
+ across calls to `.test()` / `.exec()`, so a shared instance resumes mid-string
78
+ on the next call. A Python compiled pattern carries no per-object scan state —
79
+ position is an argument to `.match()` / `.search()`, and `re.finditer` returns a
80
+ fresh iterator — so there is no equivalent carve-out here and every constant
81
+ `re.compile` is reported.
82
+
83
+ There is no autofix: hoisting has to pick an insertion point and may collide
84
+ with an existing module-scope name, and a wrong automated hoist is worse than
85
+ the warning.
86
+
87
+ A deliberate per-call rebuild (a fresh mutable default the rule cannot see
88
+ through, say) is suppressed with `# sarj-noqa: SARJ039 — <reason>`.
89
+ """
90
+
91
+ from __future__ import annotations
92
+
93
+ import ast
94
+ from dataclasses import dataclass
95
+ from typing import TYPE_CHECKING, override
96
+
97
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
98
+ from sarj_python_lint.rules._paths import is_generated_source, is_test_path
99
+
100
+
101
+ if TYPE_CHECKING:
102
+ from collections.abc import Iterator
103
+ from pathlib import Path
104
+
105
+
106
+ type _Function = ast.FunctionDef | ast.AsyncFunctionDef
107
+
108
+ #: A display smaller than this reads better next to its use than as a
109
+ #: module-level constant, so this is the floor at which hoisting pays for itself.
110
+ _MIN_ELEMENTS = 3
111
+
112
+ #: How deep a nested display may go before we stop trying to prove it constant.
113
+ _MAX_LITERAL_DEPTH = 4
114
+
115
+ #: `re.compile(pattern[, flags])` — anything longer is not the shape we model.
116
+ _COMPILE_MAX_ARGS = 2
117
+
118
+ _REGEX_KIND = "regex"
119
+ _FROZENSET_KIND = "frozenset"
120
+
121
+ _RE_COMPILE = "re.compile"
122
+ _FROZENSET = "frozenset"
123
+ _RE_FLAG_PREFIX = "re."
124
+
125
+ #: Callables that read their argument and return a fresh value without retaining
126
+ #: or mutating the original, so passing the binding to one is still a read.
127
+ _SAFE_CALLEES = frozenset(
128
+ {
129
+ "all",
130
+ "any",
131
+ "dict",
132
+ "enumerate",
133
+ "frozenset",
134
+ "iter",
135
+ "json.dumps",
136
+ "len",
137
+ "list",
138
+ "max",
139
+ "min",
140
+ "reversed",
141
+ "set",
142
+ "sorted",
143
+ "sum",
144
+ "tuple",
145
+ }
146
+ )
147
+
148
+ #: Collection methods known not to mutate the receiver. Everything else is
149
+ #: assumed to mutate — default-deny, so an unrecognised method suppresses the
150
+ #: report rather than risking a hoist that shares mutable state across calls.
151
+ _SAFE_METHODS = frozenset({"copy", "count", "get", "index", "items", "keys", "values"})
152
+
153
+ #: The `re.Pattern` API. A compiled pattern is immutable and, unlike a JavaScript
154
+ #: RegExp, carries no scan state, so these are all pure reads — but the list is
155
+ #: still explicit, so an unrecognised method on a pattern bails like any other.
156
+ _SAFE_REGEX_METHODS = frozenset(
157
+ {
158
+ "findall",
159
+ "finditer",
160
+ "fullmatch",
161
+ "match",
162
+ "search",
163
+ "split",
164
+ "sub",
165
+ "subn",
166
+ }
167
+ )
168
+
169
+ _FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
170
+
171
+ #: Nodes that open a scope of their own. A reference to the binding inside one
172
+ #: is a capture, which outlives the call and therefore bails.
173
+ _INNER_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
174
+
175
+
176
+ class PreferModuleLevelConstant(Rule):
177
+ """Literal-only collection or compiled regex rebuilt per call — hoist to module scope."""
178
+
179
+ id: str = "prefer-module-level-constant"
180
+ code: str = "SARJ039"
181
+ description: str = (
182
+ "a literal-only collection or compiled regex built inside a function is "
183
+ "rebuilt on every call — hoist it to module scope."
184
+ )
185
+
186
+ @override
187
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
188
+ if is_test_path(path) or is_generated_source(source):
189
+ return []
190
+ tree = parse_or_none(path, source)
191
+ if tree is None:
192
+ return []
193
+ diags: list[Diagnostic] = []
194
+ for func in _iter_functions(tree):
195
+ for stmt, name, candidate in _hoistable_bindings(func):
196
+ diags.append(
197
+ Diagnostic(
198
+ path=path,
199
+ line=stmt.lineno,
200
+ col=stmt.col_offset + 1,
201
+ code=self.code,
202
+ message=_message(name, candidate),
203
+ )
204
+ )
205
+ diags.sort(key=lambda d: (d.line, d.col))
206
+ return diags
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class _Candidate:
211
+ """A classified initializer: what kind of value it is and how many entries it has."""
212
+
213
+ kind: str
214
+ size: int
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class _Scope:
219
+ """One function body flattened: every descendant node, its parent, its nesting."""
220
+
221
+ nodes: list[ast.AST]
222
+ parents: dict[int, ast.AST]
223
+ nested: set[int]
224
+
225
+
226
+ def _iter_functions(tree: ast.Module) -> Iterator[_Function]:
227
+ """Walk the module for every `def` / `async def`, nested ones included.
228
+
229
+ Yields:
230
+ Each function node in the module.
231
+
232
+ """
233
+ for node in ast.walk(tree):
234
+ if isinstance(node, _FUNCTION_NODES):
235
+ yield node
236
+
237
+
238
+ def _hoistable_bindings(func: _Function) -> Iterator[tuple[ast.stmt, str, _Candidate]]:
239
+ """Find the bindings in this function's own scope that are safe to hoist.
240
+
241
+ Yields:
242
+ The assignment statement, the bound name, and its classified value.
243
+
244
+ """
245
+ scope = _scope_of(func)
246
+ for node in scope.nodes:
247
+ if id(node) in scope.nested or not isinstance(node, ast.stmt):
248
+ continue
249
+ binding = _candidate_binding(node)
250
+ if binding is None:
251
+ continue
252
+ target, value = binding
253
+ candidate = _classify(value)
254
+ if candidate is None or not _is_large_enough(candidate):
255
+ continue
256
+ if _is_safely_hoistable(scope, func, target, _safe_methods_for(candidate)):
257
+ yield node, target.id, candidate
258
+
259
+
260
+ def _scope_of(func: _Function) -> _Scope:
261
+ """Flatten the function body into nodes, parent links, and nesting flags.
262
+
263
+ A node is "nested" when it lives inside an inner `def` / `lambda` / `class`,
264
+ i.e. in a scope that can outlive or re-enter the enclosing call.
265
+
266
+ Returns:
267
+ The flattened view of the function body.
268
+
269
+ """
270
+ nodes: list[ast.AST] = []
271
+ parents: dict[int, ast.AST] = {}
272
+ nested: set[int] = set()
273
+ stack: list[tuple[ast.AST, ast.AST, bool]] = [(child, func, False) for child in func.body]
274
+ while stack:
275
+ node, parent, is_nested = stack.pop()
276
+ nodes.append(node)
277
+ parents[id(node)] = parent
278
+ if is_nested:
279
+ nested.add(id(node))
280
+ child_nested = is_nested or isinstance(node, _INNER_SCOPE_NODES)
281
+ stack.extend((child, node, child_nested) for child in ast.iter_child_nodes(node))
282
+ return _Scope(nodes=nodes, parents=parents, nested=nested)
283
+
284
+
285
+ def _candidate_binding(node: ast.stmt) -> tuple[ast.Name, ast.expr] | None:
286
+ """Match a single-target `x = <value>` / `x: T = <value>` statement.
287
+
288
+ Returns:
289
+ The bound name and its initializer, or None for any other statement.
290
+
291
+ """
292
+ match node:
293
+ case ast.Assign(targets=[ast.Name() as target], value=value):
294
+ return target, value
295
+ case ast.AnnAssign(target=ast.Name() as target, value=ast.expr() as value):
296
+ return target, value
297
+ case _:
298
+ return None
299
+
300
+
301
+ def _classify(value: ast.expr) -> _Candidate | None:
302
+ """Classify the initializer as a constant-only display, a `frozenset`, or a regex.
303
+
304
+ Returns:
305
+ The candidate kind and top-level size, or None when it does not qualify.
306
+
307
+ """
308
+ match value:
309
+ case ast.List(elts=elts):
310
+ return _display_candidate("list", value, len(elts))
311
+ case ast.Set(elts=elts):
312
+ return _display_candidate("set", value, len(elts))
313
+ case ast.Tuple(elts=elts):
314
+ return _display_candidate("tuple", value, len(elts))
315
+ case ast.Dict(keys=keys):
316
+ return _display_candidate("dict", value, len(keys))
317
+ case ast.Call():
318
+ return _call_candidate(value)
319
+ case _:
320
+ return None
321
+
322
+
323
+ def _is_large_enough(candidate: _Candidate) -> bool:
324
+ """Apply the `_MIN_ELEMENTS` floor, which a compiled regex is exempt from.
325
+
326
+ Returns:
327
+ True when the candidate is worth hoisting on size grounds.
328
+
329
+ """
330
+ return candidate.kind == _REGEX_KIND or candidate.size >= _MIN_ELEMENTS
331
+
332
+
333
+ def _display_candidate(kind: str, node: ast.expr, size: int) -> _Candidate | None:
334
+ """Accept a display only when every one of its leaves is a constant.
335
+
336
+ Returns:
337
+ The candidate, or None when any leaf is non-constant.
338
+
339
+ """
340
+ return _Candidate(kind=kind, size=size) if _is_constant_only(node, 0) else None
341
+
342
+
343
+ def _call_candidate(call: ast.Call) -> _Candidate | None:
344
+ """Classify `frozenset([...])` and `re.compile("...")` call initializers.
345
+
346
+ Returns:
347
+ The candidate, or None for any other call.
348
+
349
+ """
350
+ if call.keywords:
351
+ return None
352
+ callee = _dotted_name(call.func)
353
+ if callee == _FROZENSET:
354
+ return _frozenset_candidate(call)
355
+ if callee == _RE_COMPILE and _is_constant_pattern(call):
356
+ return _Candidate(kind=_REGEX_KIND, size=1)
357
+ return None
358
+
359
+
360
+ def _frozenset_candidate(call: ast.Call) -> _Candidate | None:
361
+ """Accept `frozenset(<constant-only display>)`.
362
+
363
+ Returns:
364
+ The candidate, or None when the sole argument is not a constant display.
365
+
366
+ """
367
+ match call.args:
368
+ case [ast.List(elts=elts) | ast.Set(elts=elts) | ast.Tuple(elts=elts) as inner] if _is_constant_only(inner, 0):
369
+ return _Candidate(kind=_FROZENSET_KIND, size=len(elts))
370
+ case _:
371
+ return None
372
+
373
+
374
+ def _is_constant_pattern(call: ast.Call) -> bool:
375
+ """Report whether a `re.compile(...)` call takes a literal pattern and constant flags.
376
+
377
+ Returns:
378
+ True when the compiled pattern is fully determined at import time.
379
+
380
+ """
381
+ if not call.args or len(call.args) > _COMPILE_MAX_ARGS:
382
+ return False
383
+ match call.args[0]:
384
+ case ast.Constant(value=str() | bytes()):
385
+ pass
386
+ case _:
387
+ return False
388
+ return len(call.args) < _COMPILE_MAX_ARGS or _is_constant_flags(call.args[1])
389
+
390
+
391
+ def _is_constant_flags(node: ast.expr) -> bool:
392
+ """Report whether a `re.compile` flags argument is an import-time constant.
393
+
394
+ Accepts an int literal, an `re.<FLAG>` attribute, and `|` combinations of
395
+ those — the only flag shapes real code uses.
396
+
397
+ Returns:
398
+ True when the flags expression is constant.
399
+
400
+ """
401
+ match node:
402
+ case ast.Constant(value=int()):
403
+ return True
404
+ case ast.Attribute():
405
+ dotted = _dotted_name(node)
406
+ return dotted is not None and dotted.startswith(_RE_FLAG_PREFIX)
407
+ case ast.BinOp(op=ast.BitOr(), left=left, right=right):
408
+ return _is_constant_flags(left) and _is_constant_flags(right)
409
+ case _:
410
+ return False
411
+
412
+
413
+ def _is_constant_only(node: ast.expr, depth: int) -> bool:
414
+ """Report whether the expression is built entirely out of constants.
415
+
416
+ No name, no attribute, no call, no comprehension, no f-string, no spread. A
417
+ constant-only value cannot capture a parameter, cannot observe call-time
418
+ state, and cannot have side effects, which is exactly what makes the hoist
419
+ provably safe.
420
+
421
+ Returns:
422
+ True when every leaf of the expression is a literal constant.
423
+
424
+ """
425
+ if depth > _MAX_LITERAL_DEPTH:
426
+ return False
427
+ match node:
428
+ case ast.Constant():
429
+ return True
430
+ case ast.UnaryOp(op=ast.USub() | ast.UAdd(), operand=ast.Constant(value=int() | float() | complex())):
431
+ return True
432
+ case ast.List(elts=elts) | ast.Set(elts=elts) | ast.Tuple(elts=elts):
433
+ return all(_is_constant_only(element, depth + 1) for element in elts)
434
+ case ast.Dict(keys=keys, values=values):
435
+ entries = [*keys, *values]
436
+ return all(entry is not None and _is_constant_only(entry, depth + 1) for entry in entries)
437
+ case _:
438
+ return False
439
+
440
+
441
+ def _safe_methods_for(candidate: _Candidate) -> frozenset[str]:
442
+ """Pick the non-mutating method list that applies to this kind of value.
443
+
444
+ Returns:
445
+ The methods callable on the binding without mutating it.
446
+
447
+ """
448
+ return _SAFE_REGEX_METHODS if candidate.kind == _REGEX_KIND else _SAFE_METHODS
449
+
450
+
451
+ def _is_safely_hoistable(
452
+ scope: _Scope,
453
+ func: _Function,
454
+ target: ast.Name,
455
+ safe_methods: frozenset[str],
456
+ ) -> bool:
457
+ """Report whether every reference to the binding is a non-mutating, non-escaping read.
458
+
459
+ Default-deny: the binding must be bound exactly once and every other
460
+ reference must be recognised as safe, so an unfamiliar usage suppresses the
461
+ report rather than risking a hoist that shares mutable state across calls.
462
+
463
+ Returns:
464
+ True when the binding can be moved to module scope without changing behaviour.
465
+
466
+ """
467
+ name = target.id
468
+ if name in _parameter_names(func):
469
+ return False
470
+ for node in scope.nodes:
471
+ if id(node) in scope.nested:
472
+ if _mentions_name(node, name):
473
+ return False
474
+ continue
475
+ if _rebinds_name(node, name):
476
+ return False
477
+ if not (isinstance(node, ast.Name) and node.id == name):
478
+ continue
479
+ if not isinstance(node.ctx, ast.Load):
480
+ if node is not target:
481
+ return False
482
+ elif not _is_safe_read(node, scope.parents, safe_methods):
483
+ return False
484
+ return True
485
+
486
+
487
+ def _parameter_names(func: _Function) -> frozenset[str]:
488
+ """Collect every parameter name of the function, variadics included.
489
+
490
+ Returns:
491
+ The parameter names, which the binding may not shadow.
492
+
493
+ """
494
+ args = func.args
495
+ params = [*args.posonlyargs, *args.args, *args.kwonlyargs]
496
+ params.extend(arg for arg in (args.vararg, args.kwarg) if arg is not None)
497
+ return frozenset(param.arg for param in params)
498
+
499
+
500
+ def _mentions_name(node: ast.AST, name: str) -> bool:
501
+ """Report whether this node inside an inner scope refers to the binding.
502
+
503
+ Returns:
504
+ True when an inner `def` / `lambda` / `class` captures or shadows the name.
505
+
506
+ """
507
+ match node:
508
+ case ast.Name(id=ident):
509
+ return ident == name
510
+ case ast.arg(arg=ident):
511
+ return ident == name
512
+ case ast.Global(names=names) | ast.Nonlocal(names=names):
513
+ return name in names
514
+ case _:
515
+ return False
516
+
517
+
518
+ def _rebinds_name(node: ast.AST, name: str) -> bool:
519
+ """Report whether this node binds the name through something other than a `Name` store.
520
+
521
+ Covers the binding forms that carry the name as a plain string:
522
+ `except ... as x`, `global` / `nonlocal`, a nested `def` / `class`, an
523
+ `import as`, and `match` captures.
524
+
525
+ Returns:
526
+ True when the node is a second binding of the name.
527
+
528
+ """
529
+ match node:
530
+ case ast.Global(names=names) | ast.Nonlocal(names=names):
531
+ return name in names
532
+ case ast.ExceptHandler(name=bound):
533
+ return bound == name
534
+ case ast.FunctionDef(name=bound) | ast.AsyncFunctionDef(name=bound) | ast.ClassDef(name=bound):
535
+ return bound == name
536
+ case ast.alias(asname=None, name=module):
537
+ return module.split(".")[0] == name
538
+ case ast.alias(asname=str() as bound):
539
+ return bound == name
540
+ case ast.MatchAs(name=bound) | ast.MatchStar(name=bound):
541
+ return bound == name
542
+ case ast.MatchMapping(rest=rest):
543
+ return rest == name
544
+ case _:
545
+ return False
546
+
547
+
548
+ def _is_safe_read(node: ast.Name, parents: dict[int, ast.AST], safe_methods: frozenset[str]) -> bool:
549
+ """Report whether this single reference to the binding neither mutates nor escapes it.
550
+
551
+ Returns:
552
+ True when the reference is a recognised non-mutating, non-escaping read.
553
+
554
+ """
555
+ parent = parents.get(id(node))
556
+ match parent:
557
+ case ast.Attribute():
558
+ return _is_safe_method_call(parent, parents, safe_methods)
559
+ case ast.Subscript(value=value, ctx=ctx):
560
+ # `x[k]` reads; `x[k] = v` / `del x[k]` mutate. `d[x]` uses the
561
+ # binding as a key, which is a plain read whatever `d` does.
562
+ return isinstance(ctx, ast.Load) if value is node else True
563
+ case ast.Call(args=args, func=callee):
564
+ return any(arg is node for arg in args) and _is_safe_callee(callee)
565
+ case ast.keyword(arg=str(), value=value) if value is node:
566
+ grandparent = parents.get(id(parent))
567
+ return isinstance(grandparent, ast.Call) and _is_safe_callee(grandparent.func)
568
+ case ast.Compare():
569
+ return True
570
+ case ast.For(iter=iterable) | ast.AsyncFor(iter=iterable) | ast.comprehension(iter=iterable):
571
+ return iterable is node
572
+ case ast.FormattedValue():
573
+ return True
574
+ case _:
575
+ return False
576
+
577
+
578
+ def _is_safe_method_call(
579
+ attribute: ast.Attribute,
580
+ parents: dict[int, ast.AST],
581
+ safe_methods: frozenset[str],
582
+ ) -> bool:
583
+ """Report whether `x.<attr>` is an immediate call to a known non-mutating method.
584
+
585
+ A bare `x.attr` read that is not called hands the bound method out, and an
586
+ attribute store mutates, so both are unsafe.
587
+
588
+ Returns:
589
+ True when the attribute is a called method on the safe list.
590
+
591
+ """
592
+ if not isinstance(attribute.ctx, ast.Load) or attribute.attr not in safe_methods:
593
+ return False
594
+ parent = parents.get(id(attribute))
595
+ return isinstance(parent, ast.Call) and parent.func is attribute
596
+
597
+
598
+ def _is_safe_callee(func: ast.expr) -> bool:
599
+ """Report whether a call that receives the binding is known not to retain or mutate it.
600
+
601
+ Returns:
602
+ True when the callee is on the safe list.
603
+
604
+ """
605
+ return _dotted_name(func) in _SAFE_CALLEES
606
+
607
+
608
+ def _dotted_name(node: ast.expr) -> str | None:
609
+ """Render a `Name` / `Attribute` chain as a dotted string.
610
+
611
+ Returns:
612
+ The dotted name, or None when the expression is not a plain chain.
613
+
614
+ """
615
+ match node:
616
+ case ast.Name(id=ident):
617
+ return ident
618
+ case ast.Attribute(value=value, attr=attr):
619
+ base = _dotted_name(value)
620
+ return None if base is None else f"{base}.{attr}"
621
+ case _:
622
+ return None
623
+
624
+
625
+ def _message(name: str, candidate: _Candidate) -> str:
626
+ """Render the diagnostic text for one hoistable binding.
627
+
628
+ Returns:
629
+ The message naming the binding and the hoist.
630
+
631
+ """
632
+ if candidate.kind == _REGEX_KIND:
633
+ return (
634
+ f"`{name}` is a constant regex recompiled on every call — hoist it to module scope so it is compiled once."
635
+ )
636
+ return (
637
+ f"`{name}` is a constant-only {candidate.kind} rebuilt on every call — hoist it "
638
+ "to module scope so it is built once and can be imported, reused, and tested."
639
+ )