sarj-python-lint 0.12.0__tar.gz → 0.12.2__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 (42) hide show
  1. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/pyproject.toml +1 -1
  3. sarj_python_lint-0.12.2/src/sarj_python_lint/__init__.py +6 -0
  4. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/__main__.py +7 -2
  5. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/_secret_names.py +19 -5
  6. sarj_python_lint-0.12.2/src/sarj_python_lint/_version.py +20 -0
  7. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rule_base.py +18 -4
  8. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/_logging.py +5 -1
  9. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/_sql.py +15 -2
  10. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +131 -12
  11. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_comment_cruft.py +44 -1
  12. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +12 -2
  13. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_fat_try_blocks.py +53 -24
  14. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_fstring_in_log.py +15 -2
  15. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +28 -19
  16. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_offset_pagination.py +3 -1
  17. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_query_with_many_joins.py +3 -1
  18. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_secret_in_log.py +14 -4
  19. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_select_star.py +5 -1
  20. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +244 -48
  21. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_sequential_await.py +21 -3
  22. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +9 -4
  23. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_class_row.py +7 -1
  24. sarj_python_lint-0.12.2/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +213 -0
  25. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +32 -15
  26. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_str_enum.py +181 -9
  27. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +1 -0
  28. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +9 -0
  29. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +39 -6
  30. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/single_public_export.py +3 -2
  31. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/stepdown.py +187 -33
  32. sarj_python_lint-0.12.0/src/sarj_python_lint/__init__.py +0 -9
  33. sarj_python_lint-0.12.0/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -105
  34. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/.gitignore +0 -0
  35. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/README.md +0 -0
  36. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/py.typed +0 -0
  37. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/__init__.py +0 -0
  38. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/_registry.py +0 -0
  39. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  40. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  41. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  42. {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/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.12.0
3
+ Version: 0.12.2
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.12.0"
3
+ version = "0.12.2"
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" }]
@@ -0,0 +1,6 @@
1
+ """sarj-python-lint — custom Python + SQL lint rules."""
2
+
3
+ from sarj_python_lint._version import __version__
4
+
5
+
6
+ __all__ = ["__version__"]
@@ -1,4 +1,4 @@
1
- """CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>"""
1
+ """CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -106,7 +106,12 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
106
106
 
107
107
 
108
108
  def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]) -> list[Diagnostic]:
109
- """Suppress up to the baselined count per (path, code); excess diags survive."""
109
+ """Suppress up to the baselined count per (path, code); excess diags survive.
110
+
111
+ Returns:
112
+ The diagnostics that exceed the baselined count for their (path, code).
113
+
114
+ """
110
115
  seen: Counter[tuple[str, str]] = Counter()
111
116
  out: list[Diagnostic] = []
112
117
  for d in diags:
@@ -95,12 +95,16 @@ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
95
95
  _SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
96
96
 
97
97
 
98
- def _tokens(identifier: str) -> list[str]:
99
- """Ordered lowercase tokens from snake_case + camelCase decomposition.
98
+ def identifier_tokens(identifier: str) -> list[str]:
99
+ """Return ordered lowercase tokens from snake_case + camelCase decomposition.
100
100
 
101
101
  Also yields each whole snake/kebab segment lowercased, so a pathological
102
102
  mixed-case single word like `ToKeN` (which camel-splitting shreds into
103
103
  `to`/`ke`/`n`) still surfaces its intended `token` form.
104
+
105
+ Returns:
106
+ The ordered lowercase tokens decomposed from `identifier`.
107
+
104
108
  """
105
109
  tokens: list[str] = []
106
110
  for segment in _SEGMENT_RE.split(identifier):
@@ -112,8 +116,13 @@ def _tokens(identifier: str) -> list[str]:
112
116
 
113
117
 
114
118
  def is_secret_name(identifier: str) -> bool:
115
- """True if `identifier` names raw secret material (a credential, not metadata)."""
116
- tokens = _tokens(identifier)
119
+ """Report whether `identifier` names raw secret material (a credential, not metadata).
120
+
121
+ Returns:
122
+ True when `identifier` denotes a credential rather than metadata.
123
+
124
+ """
125
+ tokens = identifier_tokens(identifier)
117
126
  if tokens and tokens[-1] in _INNOCUOUS_WORDS:
118
127
  return False
119
128
  if any(tok in _SECRET_WORDS for tok in tokens):
@@ -122,5 +131,10 @@ def is_secret_name(identifier: str) -> bool:
122
131
 
123
132
 
124
133
  def _has_api_key(tokens: list[str]) -> bool:
125
- """True if `api` is immediately followed by `key` (the split form of `api_key`)."""
134
+ """Report whether `api` is immediately followed by `key` (the split form of `api_key`).
135
+
136
+ Returns:
137
+ True when an `api` token is directly followed by a `key` token.
138
+
139
+ """
126
140
  return any(a == "api" and b == "key" for a, b in pairwise(tokens))
@@ -0,0 +1,20 @@
1
+ """Resolve the installed package version, with a source-tree fallback."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ def _resolve_version() -> str:
7
+ """Return the installed distribution version, or a dev sentinel from source.
8
+
9
+ Returns:
10
+ The distribution version string, or ``"0.0.0.dev0"`` when the package is
11
+ not installed (running straight from a source checkout).
12
+
13
+ """
14
+ try:
15
+ return version("sarj-python-lint")
16
+ except PackageNotFoundError:
17
+ return "0.0.0.dev0"
18
+
19
+
20
+ __version__ = _resolve_version()
@@ -5,13 +5,13 @@ from __future__ import annotations
5
5
  from abc import ABC, abstractmethod
6
6
  import ast
7
7
  from dataclasses import dataclass
8
+ from pathlib import Path
8
9
  import re
9
10
  from typing import TYPE_CHECKING
10
11
 
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from collections.abc import Sequence
14
- from pathlib import Path
15
15
 
16
16
 
17
17
  # Suppression syntax. Two forms supported:
@@ -28,9 +28,13 @@ _SARJ_NOQA_RE = re.compile(
28
28
 
29
29
 
30
30
  def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
31
- """Return True if the diagnostic's line carries a `# sarj-noqa[: CODE]` comment.
31
+ """Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment.
32
32
 
33
33
  `line` is 1-based to match Diagnostic.line.
34
+
35
+ Returns:
36
+ True when the line is suppressed for `code`.
37
+
34
38
  """
35
39
  if line < 1 or line > len(source_lines):
36
40
  return False
@@ -57,7 +61,12 @@ class Diagnostic:
57
61
  message: str
58
62
 
59
63
  def format(self) -> str:
60
- """Ruff-compatible: `path:line:col: CODE message`."""
64
+ """Render the finding ruff-compatibly as `path:line:col: CODE message`.
65
+
66
+ Returns:
67
+ The formatted single-line diagnostic string.
68
+
69
+ """
61
70
  return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
62
71
 
63
72
 
@@ -82,7 +91,12 @@ _last_parse: tuple[tuple[str, int, int], ast.Module | None] | None = None
82
91
 
83
92
 
84
93
  def parse_or_none(path: Path, source: str) -> ast.Module | None:
85
- """Parse `source`, memoizing the most recent file so N rules share one parse."""
94
+ """Parse `source`, memoizing the most recent file so N rules share one parse.
95
+
96
+ Returns:
97
+ The parsed module, or None when `source` has a syntax error.
98
+
99
+ """
86
100
  global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
87
101
  key = (str(path), len(source), hash(source))
88
102
  if _last_parse is not None and _last_parse[0] == key:
@@ -16,11 +16,15 @@ _LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
16
16
 
17
17
 
18
18
  def is_logger_expr(expr: ast.expr) -> bool:
19
- """True if `expr` evaluates to a logger.
19
+ """Report whether `expr` evaluates to a logger.
20
20
 
21
21
  Resolves the whole receiver chain so adapter/builder/factory calls are
22
22
  caught: `logger.bind(...).info(...)`, `logger.opt(lazy=True).debug(...)`,
23
23
  `logging.getLogger(__name__).info(...)`, `self.logger.error(...)`.
24
+
25
+ Returns:
26
+ True when `expr` resolves to a logger receiver.
27
+
24
28
  """
25
29
  if isinstance(expr, ast.Name):
26
30
  return expr.id.lower() in _LOGGER_NAMES
@@ -19,19 +19,28 @@ if TYPE_CHECKING:
19
19
 
20
20
 
21
21
  def is_store_module(path: Path) -> bool:
22
- """True for a store-layer module: basename ends `_store.py`, or the file lives under a `stores/` directory.
22
+ """Report whether `path` is a store-layer module: basename ends `_store.py`, or lives under a `stores/` directory.
23
23
 
24
24
  The SQL store-lint rules (SARJ018/020/021) encode store-write semantics —
25
25
  column-naming, ON-CONFLICT upserts, no Postgres-side aggregation — that only
26
26
  apply to the store layer. Non-store SQL (Flask view handlers, a Django ORM
27
27
  SQL generator) legitimately writes `SELECT *`, bare `INSERT`, and `COUNT()`,
28
28
  so those files are out of scope.
29
+
30
+ Returns:
31
+ True when `path` belongs to the store layer.
32
+
29
33
  """
30
34
  return path.name.endswith("_store.py") or "stores" in path.parts
31
35
 
32
36
 
33
37
  def sql_string_value(node: ast.expr) -> str | None:
34
- """Reconstruct a (possibly `+`-concatenated) string literal, else None."""
38
+ """Reconstruct a (possibly `+`-concatenated) string literal, else None.
39
+
40
+ Returns:
41
+ The reconstructed string, or None when `node` is not a string literal.
42
+
43
+ """
35
44
  if isinstance(node, ast.Constant) and isinstance(node.value, str):
36
45
  return node.value
37
46
  if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
@@ -52,6 +61,10 @@ def strip_sql_noise(text: str) -> str:
52
61
  preserved so line offsets — and therefore diagnostic positions — do not
53
62
  shift. Doubled quotes (`''` / `""`) are SQL's in-string escape and keep the
54
63
  scanner inside the literal.
64
+
65
+ Returns:
66
+ `text` with string-literal contents and comment bodies blanked out.
67
+
55
68
  """
56
69
  out = list(text)
57
70
  n = len(text)
@@ -12,20 +12,29 @@ are either the prescribed remedy or a bounded per-iteration transform, not the
12
12
  O(n²) defect. Per-slot writes (`parts[i] = ...`) and idempotent rebinding
13
13
  (`x = f(x)`) are likewise excluded.
14
14
 
15
+ A target that is freshly (re)bound earlier in the same loop body — `desc = ...`
16
+ then `desc += suffix`, or a tuple unpack `obj, path = q.popleft()` then
17
+ `path += ...` — is loop-local: it starts empty each iteration, so the growth is
18
+ bounded, not cross-iteration accumulation. Only a target initialised BEFORE the
19
+ loop is a true O(n²) accumulator, so a preceding non-accumulating rebind of the
20
+ target inside the loop suppresses the diagnostic.
21
+
15
22
  References:
16
23
  - https://docs.python.org/3/library/stdtypes.html#str.join
17
24
  - https://wiki.python.org/moin/PythonSpeed/PerformanceTips
25
+
18
26
  """
19
27
 
20
28
  from __future__ import annotations
21
29
 
22
30
  import ast
23
- from typing import TYPE_CHECKING, override
31
+ from typing import TYPE_CHECKING, TypeGuard, override
24
32
 
25
33
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
26
34
 
27
35
 
28
36
  if TYPE_CHECKING:
37
+ from collections.abc import Iterator
29
38
  from pathlib import Path
30
39
 
31
40
 
@@ -61,6 +70,7 @@ class _ConcatVisitor(ast.NodeVisitor):
61
70
  def __init__(self) -> None:
62
71
  self._loop_depth: int = 0
63
72
  self._string_vars: list[frozenset[str]] = [frozenset()]
73
+ self._loop_reassigns: list[dict[str, list[int]]] = []
64
74
  self.hits: list[ast.AugAssign | ast.Assign] = []
65
75
 
66
76
  @override
@@ -75,14 +85,38 @@ class _ConcatVisitor(ast.NodeVisitor):
75
85
  return
76
86
  if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
77
87
  self._loop_depth += 1
88
+ self._loop_reassigns.append(_loop_local_reassignments(node))
78
89
  super().generic_visit(node)
90
+ self._loop_reassigns.pop()
79
91
  self._loop_depth -= 1
80
92
  return
81
- if self._loop_depth and self._is_in_loop_concat(node):
93
+ if self._loop_depth and self._is_in_loop_concat(node) and not self._is_loop_local_target(node):
82
94
  self.hits.append(node)
83
95
  super().generic_visit(node)
84
96
 
85
- def _is_in_loop_concat(self, node: ast.AST) -> bool:
97
+ def _is_loop_local_target(self, node: ast.AugAssign | ast.Assign) -> bool:
98
+ """Report whether the concat target is freshly rebound earlier this iteration.
99
+
100
+ A target rebound (not self-accumulated) before the concat inside the same
101
+ innermost loop body starts empty each pass, so its growth is bounded.
102
+
103
+ Returns:
104
+ True when the target is loop-local rather than a cross-iteration accumulator.
105
+
106
+ """
107
+ target = self._accumulation_target(node)
108
+ rebinds = self._loop_reassigns[-1].get(ast.unparse(target), ())
109
+ return any(line < node.lineno for line in rebinds)
110
+
111
+ def _accumulation_target(self, node: ast.AugAssign | ast.Assign) -> ast.expr:
112
+ if isinstance(node, ast.AugAssign):
113
+ return node.target
114
+ for target in node.targets:
115
+ if self._is_self_add_growth(target, node.value):
116
+ return target
117
+ return node.targets[0]
118
+
119
+ def _is_in_loop_concat(self, node: ast.AST) -> TypeGuard[ast.AugAssign | ast.Assign]:
86
120
  if isinstance(node, ast.AugAssign):
87
121
  return isinstance(node.op, ast.Add) and self._is_string_growth(node.target, node.value)
88
122
  if isinstance(node, ast.Assign):
@@ -90,7 +124,12 @@ class _ConcatVisitor(ast.NodeVisitor):
90
124
  return False
91
125
 
92
126
  def _is_self_add_growth(self, target: ast.expr, value: ast.expr) -> bool:
93
- """`s = s + <str>` a BinOp(Add) rebinding the target to itself-plus-more."""
127
+ """Report whether `s = s + <str>` rebinds the target to itself-plus-more.
128
+
129
+ Returns:
130
+ True when the assignment is a BinOp(Add) accumulation onto the target.
131
+
132
+ """
94
133
  if not isinstance(value, ast.BinOp) or not isinstance(value.op, ast.Add):
95
134
  return False
96
135
  other = _other_add_operand(target, value)
@@ -99,7 +138,12 @@ class _ConcatVisitor(ast.NodeVisitor):
99
138
  return self._is_string_growth(target, other)
100
139
 
101
140
  def _is_string_growth(self, target: ast.expr, rhs: ast.expr) -> bool:
102
- """True when appending `rhs` to `target` is single-string accumulation."""
141
+ """Report whether appending `rhs` to `target` is single-string accumulation.
142
+
143
+ Returns:
144
+ True when the append grows a string-typed target.
145
+
146
+ """
103
147
  if isinstance(target, ast.Subscript):
104
148
  return False
105
149
  if _looks_like_string(rhs):
@@ -109,8 +153,76 @@ class _ConcatVisitor(ast.NodeVisitor):
109
153
  return False
110
154
 
111
155
 
156
+ def _loop_local_reassignments(loop: ast.For | ast.AsyncFor | ast.While) -> dict[str, list[int]]:
157
+ """Map each target rebound inside this loop's own body to the lines that rebind it.
158
+
159
+ Only rebinds that are NOT self-accumulation (`s = s + x`) count — those are the
160
+ defect itself, not a fresh reset. Nested loops / functions / classes are their
161
+ own scope and are excluded.
162
+
163
+ Returns:
164
+ Target source string → line numbers where it is freshly (re)bound.
165
+
166
+ """
167
+ reassigns: dict[str, list[int]] = {}
168
+ for stmt in loop.body:
169
+ _collect_reassignments(stmt, reassigns)
170
+ return reassigns
171
+
172
+
173
+ def _collect_reassignments(node: ast.AST, reassigns: dict[str, list[int]]) -> None:
174
+ if isinstance(
175
+ node,
176
+ (ast.For, ast.AsyncFor, ast.While, ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef),
177
+ ):
178
+ return
179
+ if isinstance(node, ast.Assign):
180
+ for target in node.targets:
181
+ for bound in _iter_binding_targets(target):
182
+ if not _is_accumulation_assign(bound, node.value):
183
+ reassigns.setdefault(ast.unparse(bound), []).append(bound.lineno)
184
+ elif (
185
+ isinstance(node, ast.AnnAssign)
186
+ and node.value is not None
187
+ and not _is_accumulation_assign(node.target, node.value)
188
+ ):
189
+ reassigns.setdefault(ast.unparse(node.target), []).append(node.target.lineno)
190
+ for child in ast.iter_child_nodes(node):
191
+ _collect_reassignments(child, reassigns)
192
+
193
+
194
+ def _iter_binding_targets(target: ast.expr) -> Iterator[ast.Name | ast.Attribute]:
195
+ """Yield the Name / Attribute leaves a binding target rebinds.
196
+
197
+ Subscript leaves (`acc[i] = ...`) are per-slot writes, not a rebind of the
198
+ accumulator itself, so they are skipped.
199
+
200
+ Yields:
201
+ Each Name / Attribute node the target binds.
202
+
203
+ """
204
+ if isinstance(target, (ast.Tuple, ast.List)):
205
+ for elt in target.elts:
206
+ yield from _iter_binding_targets(elt)
207
+ elif isinstance(target, ast.Starred):
208
+ yield from _iter_binding_targets(target.value)
209
+ elif isinstance(target, (ast.Name, ast.Attribute)):
210
+ yield target
211
+
212
+
213
+ def _is_accumulation_assign(target: ast.expr, value: ast.expr) -> bool:
214
+ if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add):
215
+ return _other_add_operand(target, value) is not None
216
+ return False
217
+
218
+
112
219
  def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
113
- """The non-target operand of `target + x` / `x + target`, or None if absent."""
220
+ """Return the non-target operand of `target + x` / `x + target`.
221
+
222
+ Returns:
223
+ The other operand, or None if neither side matches the target.
224
+
225
+ """
114
226
  target_src = ast.unparse(target)
115
227
  if ast.unparse(binop.left) == target_src:
116
228
  return binop.right
@@ -119,17 +231,20 @@ def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
119
231
  return None
120
232
 
121
233
 
122
- def _string_typed_locals(func: ast.AST) -> frozenset[str]:
123
- """Names assigned a string-literal-ish value in this function's own body.
234
+ def _string_typed_locals(func: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda) -> frozenset[str]:
235
+ """Collect names assigned a string-literal-ish value in this function's own body.
124
236
 
125
237
  Used as the string-typed signal for bare-`Name` accumulation (`buf += line`):
126
238
  a numeric accumulator (`total = 0`) is absent, so `total += x` stays clean.
239
+
240
+ Returns:
241
+ The frozenset of locally string-typed names.
242
+
127
243
  """
128
- body = getattr(func, "body", None)
129
- if not isinstance(body, list):
244
+ if isinstance(func, ast.Lambda):
130
245
  return frozenset()
131
246
  names: set[str] = set()
132
- for stmt in body:
247
+ for stmt in func.body:
133
248
  _collect_string_targets(stmt, names)
134
249
  return frozenset(names)
135
250
 
@@ -153,11 +268,15 @@ def _collect_string_targets(node: ast.AST, names: set[str]) -> None:
153
268
 
154
269
 
155
270
  def _looks_like_string(node: ast.AST) -> bool:
156
- """Heuristic for 'this expression is obviously a string at runtime'.
271
+ """Report whether this expression is obviously a string at runtime.
157
272
 
158
273
  Deliberately conservative: a bare call (`str(x)`, `",".join(...)`,
159
274
  `os.path.join(...)`) is NOT treated as a string — those shapes also appear in
160
275
  benign one-shot reassignment and are not the accumulation defect.
276
+
277
+ Returns:
278
+ True when the expression is heuristically string-typed.
279
+
161
280
  """
162
281
  if isinstance(node, ast.Constant) and isinstance(node.value, str):
163
282
  return True
@@ -99,6 +99,26 @@ _ASSIGN_OR_CALL_RE = re.compile(r"^[A-Za-z_][\w.\[\]]*\s*(?:=|:=|\+=|-=|\*=|/=)\
99
99
  # illustration inside a doc comment, not a line that was once executed.
100
100
  _PSEUDOCODE_RE = re.compile(r"%[^%\s]+%|\[opt\]|<[^<>]+>|\.\.\.")
101
101
 
102
+ # Step-narration lead-ins ("First, ...", "Then, ...", "Finally, ...", "Step 2:").
103
+ # A trailing comma/colon is required so English adverbs ("finally the invariant
104
+ # holds") aren't mistaken for an enumeration marker.
105
+ _STEP_NARRATION_RE = re.compile(
106
+ r"^(?:first(?:ly)?|second(?:ly)?|third(?:ly)?|then|next|after(?:wards| that)?"
107
+ r"|finally|lastly|now)\s*[,:]\s*\S|^step\s+\d+\b",
108
+ re.IGNORECASE,
109
+ )
110
+
111
+ # Self-admitted meta-commentary — the "why later", not the why. Owner-tagged
112
+ # directive markers are handled elsewhere (as directives) and kept.
113
+ _META_COMMENTARY_RE = re.compile(
114
+ r"\b(?:for now|keeping (?:it|this) simple|could be (?:refactored|improved|cleaned up|simplified)"
115
+ r"|refactor(?:ed|ing)? (?:later|this)|not sure (?:if|whether|why|how)"
116
+ r"|quick[- ](?:and[- ]dirty|fix)|(?:a |bit of a )?hacky|is a hack"
117
+ r"|temporary (?:solution|workaround|fix|hack)|revisit (?:this|later|below)"
118
+ r"|clean (?:this|it) up|not ideal|placeholder for now)\b",
119
+ re.IGNORECASE,
120
+ )
121
+
102
122
 
103
123
  def _comment_body(raw: str) -> str:
104
124
  return raw.lstrip("#").strip()
@@ -120,6 +140,19 @@ def _is_directive(body: str) -> bool:
120
140
  return False
121
141
 
122
142
 
143
+ def _is_redundant_narration(body: str) -> bool:
144
+ """Whether a comment merely narrates the code (step markers, meta-commentary).
145
+
146
+ Returns:
147
+ True for step-narration lead-ins and self-admitted meta-commentary.
148
+
149
+ """
150
+ c = body.strip()
151
+ if not c or _looks_like_code(c):
152
+ return False
153
+ return bool(_STEP_NARRATION_RE.search(c) or _META_COMMENTARY_RE.search(c))
154
+
155
+
123
156
  def _is_banner(body: str) -> bool:
124
157
  if not body:
125
158
  return False
@@ -150,12 +183,16 @@ def _looks_like_code(body: str) -> bool:
150
183
 
151
184
 
152
185
  def _is_prose_line(body: str) -> bool:
153
- """Return True if `body` reads as a natural-language sentence, not code.
186
+ """Report whether `body` reads as a natural-language sentence, not code.
154
187
 
155
188
  Used to spot a doc/prose comment that immediately precedes a code-shaped
156
189
  line: `# For example:` above `# result = {**a, **b}`, or a wrapped sentence
157
190
  whose second line happens to parse as an expression. Such a line is an
158
191
  illustration / prose continuation, not commented-out code.
192
+
193
+ Returns:
194
+ True when `body` reads as prose.
195
+
159
196
  """
160
197
  c = body.strip()
161
198
  if not c or _is_banner(c) or _is_directive(c) or _looks_like_code(c):
@@ -223,6 +260,8 @@ class NoCommentCruft(Rule):
223
260
  if prev_body is not None and _is_prose_line(prev_body):
224
261
  return None
225
262
  return "Commented-out code — delete it; git history remembers."
263
+ if _is_redundant_narration(body):
264
+ return "Comment narrates the code — delete it or say why, not what. Code is self-documenting."
226
265
  return None
227
266
 
228
267
  def _flag_leading_preamble(
@@ -272,6 +311,10 @@ def _standalone_comments(source: str) -> tuple[list[tuple[int, int, str]], int]:
272
311
 
273
312
  A comment is standalone when it is the only content on its line. `first code
274
313
  line` is the row of the first real code token (a large sentinel if none).
314
+
315
+ Returns:
316
+ The standalone comments and the first code line's row.
317
+
275
318
  """
276
319
  out: list[tuple[int, int, str]] = []
277
320
  first_code_line = 1 << 30
@@ -15,6 +15,7 @@ would be a valid further tightening).
15
15
 
16
16
  References:
17
17
  - https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#credentialed_requests_and_wildcards
18
+
18
19
  """
19
20
 
20
21
  from __future__ import annotations
@@ -75,15 +76,24 @@ class NoCorsWildcardWithCredentials(Rule):
75
76
 
76
77
 
77
78
  def _is_true_literal(node: ast.expr) -> bool:
78
- """True only for the literal `True` (not `1`, not a truthy expression)."""
79
+ """Report whether `node` is the literal `True` (not `1`, not a truthy expression).
80
+
81
+ Returns:
82
+ True only when `node` is the `True` constant.
83
+
84
+ """
79
85
  return isinstance(node, ast.Constant) and node.value is True
80
86
 
81
87
 
82
88
  def _contains_star_literal(node: ast.expr) -> bool:
83
- """True if a `"*"` string `Constant` appears anywhere in `node`'s subtree.
89
+ """Report whether a `"*"` string `Constant` appears anywhere in `node`'s subtree.
84
90
 
85
91
  Walking the whole subtree catches both `["*"]` and the `allowed if flag else
86
92
  ["*"]` conditional branch. A dynamic `allow_origins=some_var` has no `"*"`
87
93
  literal, so it does not fire.
94
+
95
+ Returns:
96
+ True when a `"*"` literal appears in the subtree.
97
+
88
98
  """
89
99
  return any(isinstance(child, ast.Constant) and child.value == "*" for child in ast.walk(node))