sarj-python-lint 0.12.2__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.12.2 → sarj_python_lint-0.14.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/pyproject.toml +1 -1
  3. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/_paths.py +58 -0
  4. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_registry.py +19 -0
  5. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +91 -1
  6. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/kwonly_same_type_params.py +261 -0
  7. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_comment_cruft.py +49 -1
  8. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/no_file_level_suppression.py +228 -0
  9. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +162 -0
  10. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +69 -1
  11. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sequential_await.py +33 -1
  12. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +22 -10
  13. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +53 -19
  14. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/prefer_match_assert_never.py +624 -0
  15. sarj_python_lint-0.14.0/src/sarj_python_lint/rules/prefer_module_level_constant.py +639 -0
  16. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +55 -0
  17. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_str_enum.py +50 -1
  18. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +8 -0
  19. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +7 -0
  20. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +10 -2
  21. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/.gitignore +0 -0
  22. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/README.md +0 -0
  23. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__init__.py +0 -0
  24. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__main__.py +0 -0
  25. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_secret_names.py +0 -0
  26. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_version.py +0 -0
  27. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/py.typed +0 -0
  28. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rule_base.py +0 -0
  29. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  30. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  31. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  33. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  34. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  35. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  36. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  37. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  38. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  39. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  40. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  41. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  42. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  43. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  44. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  45. {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  46. {sarj_python_lint-0.12.2 → 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.12.2
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.12.2"
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" }]
@@ -0,0 +1,58 @@
1
+ """Shared file-scope predicates for the file-scope-gated rules.
2
+
3
+ A single definition of "is this a test file?" so the test-scoped rules
4
+ (SARJ031 no-sleep-in-test-body, SARJ036 no-raw-sql-in-tests) and the rules
5
+ that *exempt* tests (SARJ014 prefer-timedelta-for-durations, SARJ015
6
+ prefer-struct-over-namedtuple, SARJ026 prefer-namedtuple-over-tuple-return,
7
+ SARJ034 kwonly-same-type-params) never diverge on what counts as a test file.
8
+
9
+ Also the single definition of "is this a generated file?" (SARJ016/034):
10
+ generated code mirrors whatever its generator emits, so style rules cannot be
11
+ acted on there.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import TYPE_CHECKING
18
+
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+
24
+ _TEST_DIR_NAMES = frozenset({"tests", "test"})
25
+
26
+ _GENERATED_RE = re.compile(r"auto-?generated|generated by|do not edit", re.IGNORECASE)
27
+
28
+ _GENERATED_HEADER_LINES = 5
29
+
30
+
31
+ def is_generated_source(source: str) -> bool:
32
+ """Report whether `source` self-identifies as generated code.
33
+
34
+ Checks the first few lines for the conventional markers
35
+ ("autogenerated", "generated by", "do not edit").
36
+
37
+ Returns:
38
+ True when the file header carries a generated-code marker.
39
+
40
+ """
41
+ head = source.splitlines()[:_GENERATED_HEADER_LINES]
42
+ return any(_GENERATED_RE.search(line) for line in head)
43
+
44
+
45
+ def is_test_path(path: Path) -> bool:
46
+ """Report whether `path` is a test file.
47
+
48
+ A test file is `conftest.py`, a `test_*.py` / `*_test.py` stem, or any file
49
+ under a `tests` / `test` directory segment.
50
+
51
+ Returns:
52
+ True when `path` belongs to the test tree.
53
+
54
+ """
55
+ name = path.name
56
+ if name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py"):
57
+ return True
58
+ return any(part in _TEST_DIR_NAMES for part in path.parts)
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
5
5
  from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
6
6
  InefficientStringConcatInLoop,
7
7
  )
8
+ from sarj_python_lint.rules.kwonly_same_type_params import KwonlySameTypeParams
8
9
  from sarj_python_lint.rules.no_aggregation_in_store_query import (
9
10
  NoAggregationInStoreQuery,
10
11
  )
@@ -13,10 +14,12 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
13
14
  NoCorsWildcardWithCredentials,
14
15
  )
15
16
  from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
17
+ from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
16
18
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
17
19
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
18
20
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
19
21
  from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
22
+ from sarj_python_lint.rules.no_raw_sql_in_tests import NoRawSqlInTests
20
23
  from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
21
24
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
22
25
  from sarj_python_lint.rules.no_select_star import NoSelectStar
@@ -30,6 +33,10 @@ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
30
33
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
31
34
  PreferConstantTimeSecretCompare,
32
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
+ )
33
40
  from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
34
41
  PreferNamedtupleOverTupleReturn,
35
42
  )
@@ -52,6 +59,13 @@ if TYPE_CHECKING:
52
59
  from sarj_python_lint.rule_base import Rule
53
60
 
54
61
 
62
+ # Retired codes — never reuse these for new rules:
63
+ # SARJ004, SARJ005 (retired before the standards merge),
64
+ # SARJ027, SARJ029, SARJ030 (dropped in 0.11.1 as too noisy),
65
+ # SARJ033 httpx-client-requires-timeout, SARJ035 no-import-time-settings
66
+ # (dropped by user veto after the 0.13.x mined-rules review),
67
+ # SARJ037 no-trivial-single-use-helper (prototyped and dropped for FP rate;
68
+ # see the 0.13.1 inlining commit for the corpus analysis).
55
69
  REGISTRY: dict[str, type[Rule]] = {
56
70
  NoSequentialAwait.id: NoSequentialAwait,
57
71
  InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
@@ -79,6 +93,11 @@ REGISTRY: dict[str, type[Rule]] = {
79
93
  SinglePublicExport.id: SinglePublicExport,
80
94
  Stepdown.id: Stepdown,
81
95
  NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
96
+ PreferMatchAssertNever.id: PreferMatchAssertNever,
97
+ KwonlySameTypeParams.id: KwonlySameTypeParams,
98
+ NoRawSqlInTests.id: NoRawSqlInTests,
99
+ NoFileLevelSuppression.id: NoFileLevelSuppression,
100
+ PreferModuleLevelConstant.id: PreferModuleLevelConstant,
82
101
  }
83
102
 
84
103
  __all__ = ["REGISTRY"]
@@ -19,6 +19,17 @@ bounded, not cross-iteration accumulation. Only a target initialised BEFORE the
19
19
  loop is a true O(n²) accumulator, so a preceding non-accumulating rebind of the
20
20
  target inside the loop suppresses the diagnostic.
21
21
 
22
+ A target whose intermediate values are CONSUMED per iteration is a probe, not
23
+ an accumulator — `"".join` at the end cannot replace it. Two consumption
24
+ shapes are exempt (both minimized from pydantic's unique-name generation):
25
+
26
+ * the enclosing `while` test reads the target
27
+ (`while name in taken: name += "_"`), or
28
+ * the loop body reads the target outside its own accumulation statement
29
+ (`globals.setdefault(reference_name, model)` then `reference_name += "_"`).
30
+
31
+ A pure accumulator is only ever written inside the loop, so it keeps firing.
32
+
22
33
  References:
23
34
  - https://docs.python.org/3/library/stdtypes.html#str.join
24
35
  - https://wiki.python.org/moin/PythonSpeed/PerformanceTips
@@ -71,29 +82,66 @@ class _ConcatVisitor(ast.NodeVisitor):
71
82
  self._loop_depth: int = 0
72
83
  self._string_vars: list[frozenset[str]] = [frozenset()]
73
84
  self._loop_reassigns: list[dict[str, list[int]]] = []
85
+ self._loop_reads: list[frozenset[str]] = []
86
+ self._while_probe_names: list[frozenset[str]] = []
74
87
  self.hits: list[ast.AugAssign | ast.Assign] = []
75
88
 
76
89
  @override
77
90
  def generic_visit(self, node: ast.AST) -> None:
78
91
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
79
92
  saved_depth = self._loop_depth
93
+ saved_probes = self._while_probe_names
94
+ saved_reads = self._loop_reads
80
95
  self._loop_depth = 0
96
+ self._while_probe_names = []
97
+ self._loop_reads = []
81
98
  self._string_vars.append(_string_typed_locals(node))
82
99
  super().generic_visit(node)
83
100
  self._string_vars.pop()
84
101
  self._loop_depth = saved_depth
102
+ self._while_probe_names = saved_probes
103
+ self._loop_reads = saved_reads
85
104
  return
86
105
  if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
87
106
  self._loop_depth += 1
88
107
  self._loop_reassigns.append(_loop_local_reassignments(node))
108
+ self._loop_reads.append(_loop_read_names(node))
109
+ if isinstance(node, ast.While):
110
+ self._while_probe_names.append(frozenset(_test_names(node.test)))
89
111
  super().generic_visit(node)
112
+ if isinstance(node, ast.While):
113
+ self._while_probe_names.pop()
114
+ self._loop_reads.pop()
90
115
  self._loop_reassigns.pop()
91
116
  self._loop_depth -= 1
92
117
  return
93
- if self._loop_depth and self._is_in_loop_concat(node) and not self._is_loop_local_target(node):
118
+ if (
119
+ self._loop_depth
120
+ and self._is_in_loop_concat(node)
121
+ and not self._is_loop_local_target(node)
122
+ and not self._is_probe_target(node)
123
+ ):
94
124
  self.hits.append(node)
95
125
  super().generic_visit(node)
96
126
 
127
+ def _is_probe_target(self, node: ast.AugAssign | ast.Assign) -> bool:
128
+ """Report whether the concat target's intermediate values are consumed.
129
+
130
+ A target read by an enclosing while test, or read inside a loop body
131
+ outside its own accumulation, is a probe (unique-name generation):
132
+ every intermediate value matters, so `join` cannot replace the growth.
133
+
134
+ Returns:
135
+ True when the target is consumed per iteration rather than only grown.
136
+
137
+ """
138
+ target_src = ast.unparse(self._accumulation_target(node))
139
+ if any(target_src in names for names in self._while_probe_names):
140
+ return True
141
+ # Only the INNERMOST loop's reads consume the growth per iteration; a
142
+ # read in an outer loop body sees only the finished inner accumulation.
143
+ return bool(self._loop_reads) and target_src in self._loop_reads[-1]
144
+
97
145
  def _is_loop_local_target(self, node: ast.AugAssign | ast.Assign) -> bool:
98
146
  """Report whether the concat target is freshly rebound earlier this iteration.
99
147
 
@@ -153,6 +201,48 @@ class _ConcatVisitor(ast.NodeVisitor):
153
201
  return False
154
202
 
155
203
 
204
+ def _test_names(test: ast.expr) -> set[str]:
205
+ """Collect the source text of every Name/Attribute read in a while test.
206
+
207
+ Returns:
208
+ The unparsed reads appearing in the test expression.
209
+
210
+ """
211
+ return {ast.unparse(n) for n in ast.walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
212
+
213
+
214
+ def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]:
215
+ """Collect names READ in the loop body outside their own accumulation.
216
+
217
+ An `s += x` stores (no Load of `s`); an `s = s + x` self-read is excluded.
218
+ Any other Load — a call argument, a subscript key, a comparison — consumes
219
+ the intermediate value, which marks the target as a probe. Nested
220
+ function/lambda bodies are excluded (they run in their own scope).
221
+
222
+ Returns:
223
+ The unparsed Name/Attribute reads in the loop's own body.
224
+
225
+ """
226
+ reads: set[str] = set()
227
+ stack: list[ast.AST] = list(loop.body)
228
+ while stack:
229
+ node = stack.pop()
230
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
231
+ continue
232
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
233
+ target = node.targets[0]
234
+ if _is_accumulation_assign(target, node.value) and isinstance(node.value, ast.BinOp):
235
+ # Skip the self-read operand; still record reads in the other one.
236
+ other = _other_add_operand(target, node.value)
237
+ if other is not None:
238
+ stack.append(other)
239
+ continue
240
+ stack.extend(ast.iter_child_nodes(node))
241
+ if isinstance(node, (ast.Name, ast.Attribute)) and isinstance(node.ctx, ast.Load):
242
+ reads.add(ast.unparse(node))
243
+ return frozenset(reads)
244
+
245
+
156
246
  def _loop_local_reassignments(loop: ast.For | ast.AsyncFor | ast.While) -> dict[str, list[int]]:
157
247
  """Map each target rebound inside this loop's own body to the lines that rebind it.
158
248
 
@@ -0,0 +1,261 @@
1
+ """SARJ034: >=2 positional parameters with the same primitive annotation — swap-prone.
2
+
3
+ `def transfer(source_id: str, target_id: str)` accepts `transfer(target, source)`
4
+ without a whisper from the type checker: two positional parameters with the same
5
+ primitive type are indistinguishable at the call site, and the resulting swap
6
+ bugs pass typecheck, pass review, and fail in production. Reviewers asked for a
7
+ keyword-only marker (`*`) in ~11 PRs, always with the same suggestion block:
8
+
9
+ # flagged
10
+ def transfer(source_id: str, target_id: str) -> None: ...
11
+
12
+ # preferred — call sites must name the arguments
13
+ def transfer(*, source_id: str, target_id: str) -> None: ...
14
+
15
+ Fires only when EVERY narrowing gate holds:
16
+
17
+ * the function has >= 2 positional parameters (excluding a leading `self`/`cls`)
18
+ whose annotations are IDENTICAL and are a bare primitive (`str`, `int`,
19
+ `float`, `bool`) — primitives carry no domain meaning, so the call site has
20
+ nothing to disambiguate with. Same-typed domain objects
21
+ (`a: Money, b: Money`) are left to review: arithmetic/comparison helpers over
22
+ a domain type are often legitimately symmetric.
23
+
24
+ Never flags — these signatures cannot or should not change:
25
+
26
+ * dunder methods (`__init__`, `__eq__`, ... — protocol-pinned or pervasively
27
+ called positionally),
28
+ * `visit_*` / `test_*` functions (visitor dispatch and pytest fixtures are
29
+ framework-called with positional conventions),
30
+ * functions decorated `@override` / `@overload` / `@abstractmethod` (an
31
+ override cannot unilaterally change the parent's signature; overload/abstract
32
+ signatures are contracts),
33
+ * HTTP route handlers — any decorator of the shape
34
+ `@<name>.get/post/put/patch/delete/head/options/websocket(...)` (`router`,
35
+ `app`, `api`, ...): FastAPI binds handler parameters by NAME (path/query
36
+ keys), so the positional shape is never called swap-prone by a human,
37
+ * test files (`_paths.is_test_path`) — test fakes and helpers mirror the
38
+ signatures of the code under test and cannot unilaterally change them,
39
+ * generated files (`_paths.is_generated_source`) — the signature mirrors
40
+ whatever the generator emits (found via trio's `_generated_io_kqueue.py`),
41
+ * functions whose name is referenced as a VALUE anywhere in the module
42
+ (passed to a registry, returned, stored) — the signature is a callback
43
+ protocol shared with other implementations and cannot change unilaterally
44
+ (found via attrs' `fmt_setter` family and sphinx `app.connect` handlers),
45
+ * the implementation of `@overload`-decorated stubs — a same-named sibling
46
+ in the same scope carries `@overload`, so the impl's positional shape is
47
+ pinned by the declared overloads (found via trio's `getsockopt`),
48
+ * signatures whose same-typed params differ only by a numeric suffix
49
+ (`value_1: float, value_2: float`) — the numbering declares the function
50
+ symmetric, so argument order genuinely does not matter (found via
51
+ pydantic's `almost_equal_floats`),
52
+ * parameters that are already keyword-only (behind `*`) or positional-only
53
+ (before `/`, a deliberate positional API). Note this is per-parameter, not
54
+ per-signature: `def f(a: str, b: str, *, c: int)` is still flagged, because
55
+ `a`/`b` sit BEFORE the marker and remain swap-prone. A `*args` variadic
56
+ likewise does not shield same-type params in front of it.
57
+
58
+ Parameters with defaults still count (documented judgment call: a default does
59
+ not make the call site any less swappable).
60
+
61
+ Symmetric functions (`def add(x: int, y: int)`) where order genuinely does not
62
+ matter are suppressed with `# sarj-noqa: SARJ034 — <reason>`.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import ast
68
+ import re
69
+ from typing import TYPE_CHECKING, override
70
+
71
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
72
+ from sarj_python_lint.rules._paths import is_generated_source, is_test_path
73
+
74
+
75
+ if TYPE_CHECKING:
76
+ from pathlib import Path
77
+
78
+
79
+ _MIN_SAME_TYPE = 2
80
+
81
+ _PRIMITIVES = frozenset({"str", "int", "float", "bool"})
82
+
83
+ _EXEMPT_DECORATORS = frozenset({"override", "overload", "abstractmethod"})
84
+
85
+ _HTTP_ROUTE_METHODS = frozenset(
86
+ {"get", "post", "put", "patch", "delete", "head", "options", "websocket"}
87
+ )
88
+
89
+ _EXEMPT_NAME_PREFIXES = ("visit_", "test_")
90
+
91
+
92
+ class KwonlySameTypeParams(Rule):
93
+ """>=2 positional params sharing one primitive annotation — insert `*`."""
94
+
95
+ id: str = "kwonly-same-type-params"
96
+ code: str = "SARJ034"
97
+ description: str = (
98
+ "two or more positional parameters with the same primitive annotation "
99
+ "are swap-prone — make them keyword-only by inserting `*`."
100
+ )
101
+
102
+ @override
103
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
104
+ if is_test_path(path) or is_generated_source(source):
105
+ return []
106
+ tree = parse_or_none(path, source)
107
+ if tree is None:
108
+ return []
109
+ value_referenced = _value_referenced_names(tree)
110
+ overload_names = _overload_stub_names(tree)
111
+ diags: list[Diagnostic] = []
112
+ for node in ast.walk(tree):
113
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
114
+ continue
115
+ if _is_exempt(node):
116
+ continue
117
+ if node.name in value_referenced or node.name in overload_names:
118
+ continue
119
+ offending = _swap_prone_annotation(node.args)
120
+ if offending is None:
121
+ continue
122
+ diags.append(
123
+ Diagnostic(
124
+ path=path,
125
+ line=node.lineno,
126
+ col=node.col_offset + 1,
127
+ code=self.code,
128
+ message=(
129
+ f"`{node.name}` takes multiple positional `{offending}` "
130
+ "parameters — swap-prone at call sites; insert `*` to "
131
+ "make them keyword-only."
132
+ ),
133
+ )
134
+ )
135
+ diags.sort(key=lambda d: (d.line, d.col))
136
+ return diags
137
+
138
+
139
+ def _is_exempt(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
140
+ name = node.name
141
+ if name.startswith("__") and name.endswith("__"):
142
+ return True
143
+ if name.startswith(_EXEMPT_NAME_PREFIXES):
144
+ return True
145
+ return any(
146
+ (isinstance(dec, ast.Name) and dec.id in _EXEMPT_DECORATORS)
147
+ or (isinstance(dec, ast.Attribute) and dec.attr in _EXEMPT_DECORATORS)
148
+ or _is_route_decorator(dec)
149
+ for dec in node.decorator_list
150
+ )
151
+
152
+
153
+ def _is_route_decorator(dec: ast.expr) -> bool:
154
+ """Report whether `dec` is an HTTP-route decorator like `@router.get(...)`.
155
+
156
+ Matches `<Name>.<http method>` — optionally called — for any receiver name
157
+ (`router`, `app`, `api`, ...). FastAPI binds handler parameters by name, so
158
+ a route handler's positional shape is not swap-prone at any call site.
159
+
160
+ Returns:
161
+ True when the decorator is an HTTP-route registration.
162
+
163
+ """
164
+ target = dec.func if isinstance(dec, ast.Call) else dec
165
+ match target:
166
+ case ast.Attribute(value=ast.Name(), attr=attr) if attr in _HTTP_ROUTE_METHODS:
167
+ return True
168
+ case _:
169
+ return False
170
+
171
+
172
+ def _swap_prone_annotation(args: ast.arguments) -> str | None:
173
+ """Find a primitive annotation shared by >= 2 swap-prone positional parameters.
174
+
175
+ A leading `self`/`cls` is excluded. Only bare-`Name` primitive annotations
176
+ participate — `str | None`, `Literal[...]`, and domain types never group.
177
+ Only `args.args` (positional-or-keyword) parameters count: keyword-only
178
+ parameters (behind `*`) cannot be swapped positionally, and positional-only
179
+ parameters (before `/`) are a deliberate positional API. A `*`/`*args`/`/`
180
+ marker therefore exempts exactly the parameters it protects — never the
181
+ same-type pair sitting in front of it.
182
+
183
+ A group whose parameter names differ only by a numeric suffix
184
+ (`value_1`/`value_2`) is a declared-symmetric signature and never groups.
185
+
186
+ Returns:
187
+ The offending primitive name, or None when the signature is fine.
188
+
189
+ """
190
+ params = list(args.args)
191
+ if params and params[0].arg in {"self", "cls"}:
192
+ params = params[1:]
193
+ groups: dict[str, list[str]] = {}
194
+ for p in params:
195
+ if isinstance(ann := p.annotation, ast.Name) and ann.id in _PRIMITIVES:
196
+ groups.setdefault(ann.id, []).append(p.arg)
197
+ for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
198
+ if len(arg_names) >= _MIN_SAME_TYPE and not _is_symmetric_numbering(arg_names):
199
+ return name
200
+ return None
201
+
202
+
203
+ _NUMERIC_SUFFIX_RE = re.compile(r"_?\d+$")
204
+
205
+
206
+ def _is_symmetric_numbering(arg_names: list[str]) -> bool:
207
+ """Report whether every name in the group is one stem plus a numeric suffix.
208
+
209
+ `value_1`/`value_2` (or `x1`/`x2`) declare a symmetric function — argument
210
+ order genuinely does not matter, so the group is not swap-prone.
211
+
212
+ Returns:
213
+ True when all names share one stem and differ only by a number.
214
+
215
+ """
216
+ stems = {_NUMERIC_SUFFIX_RE.sub("", name) for name in arg_names}
217
+ return len(stems) == 1 and all(_NUMERIC_SUFFIX_RE.search(name) for name in arg_names)
218
+
219
+
220
+ def _value_referenced_names(tree: ast.AST) -> frozenset[str]:
221
+ """Names referenced as a VALUE (loaded but not called) anywhere in the module.
222
+
223
+ A function whose name appears as a bare value — passed to a registry,
224
+ returned, stored in a collection — implements a callback protocol whose
225
+ positional shape is shared with other implementations; it cannot go
226
+ keyword-only unilaterally.
227
+
228
+ Returns:
229
+ The set of names loaded outside call position.
230
+
231
+ """
232
+ call_funcs = {
233
+ id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)
234
+ }
235
+ return frozenset(
236
+ node.id
237
+ for node in ast.walk(tree)
238
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and id(node) not in call_funcs
239
+ )
240
+
241
+
242
+ def _overload_stub_names(tree: ast.AST) -> frozenset[str]:
243
+ """Names carrying an `@overload` decorator anywhere in the module.
244
+
245
+ The undecorated implementation of an overloaded function shares its name
246
+ with the stubs; its positional shape is pinned by the declared overloads.
247
+
248
+ Returns:
249
+ The set of `@overload`-decorated function names.
250
+
251
+ """
252
+ return frozenset(
253
+ node.name
254
+ for node in ast.walk(tree)
255
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
256
+ and any(
257
+ (isinstance(dec, ast.Name) and dec.id == "overload")
258
+ or (isinstance(dec, ast.Attribute) and dec.attr == "overload")
259
+ for dec in node.decorator_list
260
+ )
261
+ )
@@ -25,7 +25,17 @@ under a prose lead-in (`# For example:`, a wrapped sentence) or carries
25
25
  pseudo-code markers (`%sent%`, `[opt]`, `<FunctionBody>`, `...`); and directive
26
26
  comments — `# type:`, `# noqa`, `# sarj-noqa`,
27
27
  `# pragma:`, `# pyright:`, `# mypy:`, `# fmt:`, `# isort:`, `# ruff:`,
28
- `# nosec`, `# TODO`, `# FIXME`, shebangs, and coding declarations.
28
+ `# nosec`, `# TODO`, `# FIXME`, `# language=` (IDE injection), shebangs, and
29
+ coding declarations.
30
+
31
+ Also NOT flagged (famous-repo sweep hardening):
32
+ - generated files (`_paths.is_generated_source`) — their banners are the
33
+ generator's warning header, not hand-written cruft;
34
+ - a punctuation-only "banner" directly beneath a texty comment line — that is
35
+ an RST-style heading underline or an ASCII-diagram row inside a prose comment
36
+ essay (trio's epoll/socket design essays), not a code-section separator;
37
+ - step narration that carries a rationale marker (`because`, `since`,
38
+ `so that`, `otherwise`) — "First, take the lock, because ..." is a *why*.
29
39
 
30
40
  Suppress an intentional case with `# sarj-noqa: SARJ016 — <reason>`.
31
41
  """
@@ -39,6 +49,7 @@ import tokenize
39
49
  from typing import TYPE_CHECKING, override
40
50
 
41
51
  from sarj_python_lint.rule_base import Diagnostic, Rule
52
+ from sarj_python_lint.rules._paths import is_generated_source
42
53
 
43
54
 
44
55
  if TYPE_CHECKING:
@@ -67,6 +78,7 @@ _DIRECTIVE_PREFIXES = (
67
78
  "hack",
68
79
  "xxx:",
69
80
  "-*-",
81
+ "language=",
70
82
  )
71
83
 
72
84
  _LICENSE_RE = re.compile(
@@ -108,6 +120,10 @@ _STEP_NARRATION_RE = re.compile(
108
120
  re.IGNORECASE,
109
121
  )
110
122
 
123
+ # A rationale marker turns step narration into a legitimate *why* comment
124
+ # ("First, we take the outer send lock, because of Trio's standard semantics").
125
+ _RATIONALE_RE = re.compile(r"\b(?:because|since|so that|otherwise)\b", re.IGNORECASE)
126
+
111
127
  # Self-admitted meta-commentary — the "why later", not the why. Owner-tagged
112
128
  # directive markers are handled elsewhere (as directives) and kept.
113
129
  _META_COMMENTARY_RE = re.compile(
@@ -150,9 +166,33 @@ def _is_redundant_narration(body: str) -> bool:
150
166
  c = body.strip()
151
167
  if not c or _looks_like_code(c):
152
168
  return False
169
+ if _RATIONALE_RE.search(c):
170
+ return False # "First, take the lock, because ..." carries a why
153
171
  return bool(_STEP_NARRATION_RE.search(c) or _META_COMMENTARY_RE.search(c))
154
172
 
155
173
 
174
+ def _is_heading_underline(body: str, prev_body: str | None) -> bool:
175
+ """Report whether a punctuation-only banner underlines a texty comment line.
176
+
177
+ An RST-style heading underline (`# Literature review` / `# -----------`) or
178
+ an ASCII-diagram row directly beneath a texty row lives INSIDE a prose
179
+ comment block — it is typography, not a code-section separator.
180
+
181
+ Returns:
182
+ True when the banner sits directly under a texty, non-banner comment.
183
+
184
+ """
185
+ if not _BANNER_FULL_RE.match(body):
186
+ return False
187
+ if prev_body is None:
188
+ return False
189
+ return (
190
+ any(_is_word_char(ch) for ch in prev_body)
191
+ and not _is_banner(prev_body)
192
+ and not _looks_like_code(prev_body)
193
+ )
194
+
195
+
156
196
  def _is_banner(body: str) -> bool:
157
197
  if not body:
158
198
  return False
@@ -236,6 +276,12 @@ class NoCommentCruft(Rule):
236
276
 
237
277
  @override
238
278
  def check(self, path: Path, source: str) -> list[Diagnostic]:
279
+ if is_generated_source(source):
280
+ return []
281
+ # A Sphinx `docs/**/conf.py` is quickstart-generated boilerplate whose
282
+ # `# -- Section ----` banners are the tool's own convention.
283
+ if path.name == "conf.py" and "docs" in path.parts:
284
+ return []
239
285
  try:
240
286
  standalone, first_code_line = _standalone_comments(source)
241
287
  except tokenize.TokenError, IndentationError, SyntaxError:
@@ -255,6 +301,8 @@ class NoCommentCruft(Rule):
255
301
  @staticmethod
256
302
  def _classify(body: str, prev_body: str | None) -> str | None:
257
303
  if _is_banner(body):
304
+ if _is_heading_underline(body, prev_body):
305
+ return None
258
306
  return "Section-banner / region comment — structure code with functions, not ASCII rules."
259
307
  if _looks_like_code(body):
260
308
  if prev_body is not None and _is_prose_line(prev_body):