sarj-python-lint 0.67.11__tar.gz → 0.67.12__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 (103) hide show
  1. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/.gitignore +1 -0
  2. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/PKG-INFO +1 -1
  3. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_fastapi.py +31 -8
  5. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_registry.py +12 -0
  6. sarj_python_lint-0.67.12/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +193 -0
  7. sarj_python_lint-0.67.12/src/sarj_python_lint/rules/no_apirouter_root_trailing_slash.py +120 -0
  8. sarj_python_lint-0.67.12/src/sarj_python_lint/rules/no_unique_violation_message_match.py +175 -0
  9. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/LICENSE +0 -0
  10. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/README.md +0 -0
  11. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  29. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_pytest.py +0 -0
  30. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_sql.py +0 -0
  31. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  32. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  33. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  34. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  35. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  36. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  37. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  38. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  39. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  40. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  41. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  42. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  43. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  44. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  45. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  46. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  47. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  48. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  49. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  50. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  51. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  52. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  53. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  54. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  55. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  56. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  57. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  58. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  59. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  60. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  61. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  62. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  63. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  64. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  65. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  66. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  67. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  68. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  69. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  70. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  71. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  72. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  73. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  74. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  75. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  76. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  77. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  78. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  79. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  80. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  81. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  82. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  83. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  84. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  85. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  86. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  87. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  88. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  89. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  90. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  91. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  92. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  93. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  94. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  95. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  96. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  97. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  98. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  99. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/stepdown.py +0 -0
  100. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  101. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  102. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  103. {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -37,3 +37,4 @@ Thumbs.db
37
37
  !.claude/settings.json
38
38
  .worktrees/
39
39
  .sarj-private-refs.toml
40
+ .sarj-standards-rollout.toml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.67.11
3
+ Version: 0.67.12
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.67.11"
3
+ version = "0.67.12"
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" }]
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import ast
6
6
  from collections import Counter
7
7
  from dataclasses import dataclass
8
- from typing import TYPE_CHECKING, NamedTuple
8
+ from typing import TYPE_CHECKING, Literal, NamedTuple
9
9
 
10
10
 
11
11
  if TYPE_CHECKING:
@@ -41,6 +41,7 @@ class Route:
41
41
  receiver: str
42
42
  method: str
43
43
  path: str | None
44
+ receiver_kind: Literal["FastAPI", "APIRouter"]
44
45
  inherited_hidden: bool = False
45
46
 
46
47
  @property
@@ -114,6 +115,7 @@ class FastapiIndex:
114
115
  self.type_aliases: dict[str, ast.expr] = {}
115
116
  self.receivers: set[tuple[int, str]] = set()
116
117
  self.receiver_origins: dict[tuple[int, str], tuple[int, str]] = {}
118
+ self.receiver_kinds: dict[tuple[int, str], Literal["FastAPI", "APIRouter"]] = {}
117
119
  self.hidden_receivers: set[tuple[int, str]] = set()
118
120
  self.decorators: dict[tuple[int, str], tuple[str, str]] = {}
119
121
  self.bound_names: set[tuple[int, str]] = set()
@@ -228,10 +230,20 @@ class FastapiIndex:
228
230
  if key in self.receivers or key in self.decorators:
229
231
  continue
230
232
  source = self._receiver_key(scope, _binding_name(value))
231
- if self._is_constructor_call(value) or source is not None:
233
+ constructor_kind = self._constructor_kind(value)
234
+ if constructor_kind is not None:
232
235
  self.receivers.add(key)
233
- self.receiver_origins[key] = key if source is None else self.receiver_origins[source]
234
- if self._constructor_is_hidden(value) or (source is not None and source in self.hidden_receivers):
236
+ self.receiver_origins[key] = key
237
+ self.receiver_kinds[key] = constructor_kind
238
+ if self._constructor_is_hidden(value):
239
+ self.hidden_receivers.add(key)
240
+ changed = True
241
+ continue
242
+ if source is not None:
243
+ self.receivers.add(key)
244
+ self.receiver_origins[key] = self.receiver_origins[source]
245
+ self.receiver_kinds[key] = self.receiver_kinds[source]
246
+ if source in self.hidden_receivers:
235
247
  self.hidden_receivers.add(key)
236
248
  changed = True
237
249
  continue
@@ -276,16 +288,26 @@ class FastapiIndex:
276
288
  return isinstance(value, ast.Constant) and value.value is False
277
289
 
278
290
  def _is_constructor_call(self, node: ast.expr) -> bool:
291
+ return self._constructor_kind(node) is not None
292
+
293
+ def _constructor_kind(self, node: ast.expr) -> Literal["FastAPI", "APIRouter"] | None:
279
294
  if not isinstance(node, ast.Call):
280
- return False
295
+ return None
281
296
  if isinstance(node.func, ast.Name):
282
- return node.func.id in self.constructors
283
- return (
297
+ canonical = self.symbols.get(node.func.id)
298
+ if canonical == "FastAPI":
299
+ return "FastAPI"
300
+ if canonical == "APIRouter":
301
+ return "APIRouter"
302
+ return None
303
+ if (
284
304
  isinstance(node.func, ast.Attribute)
285
305
  and isinstance(node.func.value, ast.Name)
286
306
  and node.func.value.id in self.modules
287
307
  and node.func.attr in {"FastAPI", "APIRouter"}
288
- )
308
+ ):
309
+ return "FastAPI" if node.func.attr == "FastAPI" else "APIRouter"
310
+ return None
289
311
 
290
312
  def routes(self, function: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[Route, ...]:
291
313
  routes: list[Route] = []
@@ -321,6 +343,7 @@ class FastapiIndex:
321
343
  receiver=f"{origin_scope}:{origin_name}",
322
344
  method=route_method,
323
345
  path=path,
346
+ receiver_kind=self.receiver_kinds[receiver_key],
324
347
  inherited_hidden=receiver_key in self.hidden_receivers,
325
348
  )
326
349
  for route_method in route_methods
@@ -3,6 +3,9 @@ from __future__ import annotations
3
3
  from types import MappingProxyType
4
4
  from typing import TYPE_CHECKING
5
5
 
6
+ from sarj_python_lint.rules.created_at_order_requires_tiebreaker import (
7
+ CreatedAtOrderRequiresTiebreaker,
8
+ )
6
9
  from sarj_python_lint.rules.defect_xfail_requires_strict import DefectXfailRequiresStrict
7
10
  from sarj_python_lint.rules.docstring_args_restate_signature import (
8
11
  DocstringArgsRestateSignature,
@@ -24,6 +27,9 @@ from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
24
27
  from sarj_python_lint.rules.no_aggregation_in_store_query import (
25
28
  NoAggregationInStoreQuery,
26
29
  )
30
+ from sarj_python_lint.rules.no_apirouter_root_trailing_slash import (
31
+ NoApirouterRootTrailingSlash,
32
+ )
27
33
  from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
28
34
  from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
29
35
  NoCorsWildcardWithCredentials,
@@ -58,6 +64,9 @@ from sarj_python_lint.rules.no_stdlib_logging import NoStdlibLogging
58
64
  from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
59
65
  from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
60
66
  from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
67
+ from sarj_python_lint.rules.no_unique_violation_message_match import (
68
+ NoUniqueViolationMessageMatch,
69
+ )
61
70
  from sarj_python_lint.rules.opaque_parametrize_case_needs_id import OpaqueParametrizeCaseNeedsId
62
71
  from sarj_python_lint.rules.over_mocked_test import OverMockedTest
63
72
  from sarj_python_lint.rules.phase_label_comment import TestPhaseLabelComment
@@ -125,6 +134,7 @@ if TYPE_CHECKING:
125
134
 
126
135
  REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
127
136
  {
137
+ CreatedAtOrderRequiresTiebreaker.id: CreatedAtOrderRequiresTiebreaker,
128
138
  NoStringConcatInLoop.id: NoStringConcatInLoop,
129
139
  PreferClassRow.id: PreferClassRow,
130
140
  PreferStrEnum.id: PreferStrEnum,
@@ -144,6 +154,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
144
154
  SourceCoupledTest.id: SourceCoupledTest,
145
155
  NoQueryWithManyJoins.id: NoQueryWithManyJoins,
146
156
  NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
157
+ NoApirouterRootTrailingSlash.id: NoApirouterRootTrailingSlash,
147
158
  NoSelectStar.id: NoSelectStar,
148
159
  NoGenericSingleExportModule.id: NoGenericSingleExportModule,
149
160
  Stepdown.id: Stepdown,
@@ -196,6 +207,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
196
207
  NoLongComment.id: NoLongComment,
197
208
  NoTypedDocSections.id: NoTypedDocSections,
198
209
  PreferNominalIdTypes.id: PreferNominalIdTypes,
210
+ NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
199
211
  }
200
212
  )
201
213
 
@@ -0,0 +1,193 @@
1
+ """SARJ407 — Store queries ordered by `created_at` need a later tie-break key.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_created_at_order_requires_tiebreaker.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import PurePosixPath
10
+ import re
11
+ from typing import TYPE_CHECKING, final, override
12
+
13
+ from sarj_python_lint.rule_base import (
14
+ AutofixPolicy,
15
+ Diagnostic,
16
+ ExampleFile,
17
+ ExampleOutcome,
18
+ Rule,
19
+ RuleCategory,
20
+ RuleDocumentation,
21
+ RuleExample,
22
+ Severity,
23
+ parse_or_none,
24
+ )
25
+ from sarj_python_lint.rules._ast_index import nodes, walk
26
+ from sarj_python_lint.rules._paths import is_generated
27
+ from sarj_python_lint.rules._sql import is_store_module, sql_string_value, strip_sql_noise
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ from pathlib import Path
32
+
33
+
34
+ _QUERY_SHAPE = re.compile(r"\bSELECT\b[\s\S]*?\bFROM\b", re.IGNORECASE)
35
+ _ORDER_BY = re.compile(r"\bORDER\s+BY\b", re.IGNORECASE)
36
+ _CLAUSE_BOUNDARY = re.compile(
37
+ r"\b(?:LIMIT|OFFSET|FETCH|FOR|UNION|INTERSECT|EXCEPT|RETURNING|WINDOW|QUALIFY|"
38
+ r"GROUP\s+BY|HAVING|ORDER\s+BY)\b",
39
+ re.IGNORECASE,
40
+ )
41
+ _CREATED_AT_ITEM = re.compile(
42
+ r"(?:(?:[A-Za-z_][A-Za-z0-9_$]*\s*\.\s*)*)created_at"
43
+ r"(?:\s+(?:ASC|DESC))?(?:\s+NULLS\s+(?:FIRST|LAST))?\s*\Z",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ @final
49
+ class CreatedAtOrderRequiresTiebreaker(Rule):
50
+ id = "created-at-order-requires-tiebreaker"
51
+ code = "SARJ407"
52
+ documentation = RuleDocumentation(
53
+ summary="Store queries ordered by `created_at` should include a later tie-break key.",
54
+ rationale=(
55
+ "Timestamps are not unique, so rows with the same `created_at` value have no stable relative order. "
56
+ "That can make pagination and repeated reads skip, repeat, or reorder rows."
57
+ ),
58
+ remediation="Add a stable key after `created_at`, such as `ORDER BY created_at DESC, id DESC`.",
59
+ category=RuleCategory.CORRECTNESS,
60
+ autofix=AutofixPolicy.NONE,
61
+ limitations=(
62
+ "Only fully reconstructable SQL string literals in recognized production store modules are analyzed.",
63
+ (
64
+ "The rule reports an exact, optionally qualified `created_at` column only when it is the final "
65
+ "same-depth `ORDER BY` item; any later same-depth ordering item is accepted."
66
+ ),
67
+ (
68
+ "Dynamic string construction, formatted strings, quoted identifiers, expressions containing "
69
+ "`created_at`, and non-SELECT fragments are excluded."
70
+ ),
71
+ "The intended stable key cannot be inferred safely, so the rule does not offer an autofix.",
72
+ ),
73
+ examples=(
74
+ RuleExample(
75
+ example_id="created-at-is-the-only-order-key",
76
+ title="A store query orders only by a timestamp",
77
+ outcome=ExampleOutcome.MATCH,
78
+ files=(
79
+ ExampleFile.python(
80
+ "app/task_store.py",
81
+ 'QUERY = "SELECT id, created_at FROM task ORDER BY created_at DESC LIMIT 50"\n',
82
+ ),
83
+ ),
84
+ focus_path=PurePosixPath("app/task_store.py"),
85
+ expected_count=1,
86
+ public=True,
87
+ ),
88
+ RuleExample(
89
+ example_id="created-at-is-followed-by-id",
90
+ title="A stable key breaks equal-timestamp ties",
91
+ outcome=ExampleOutcome.NO_MATCH,
92
+ files=(
93
+ ExampleFile.python(
94
+ "app/task_store.py",
95
+ 'QUERY = "SELECT id, created_at FROM task ORDER BY created_at DESC, id DESC LIMIT 50"\n',
96
+ ),
97
+ ),
98
+ focus_path=PurePosixPath("app/task_store.py"),
99
+ expected_count=0,
100
+ public=True,
101
+ ),
102
+ ),
103
+ )
104
+ description = documentation.summary
105
+
106
+ @override
107
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
108
+ if not is_store_module(path) or is_generated(path, source):
109
+ return []
110
+ tree = parse_or_none(path, source)
111
+ if tree is None:
112
+ return []
113
+
114
+ diagnostics: list[Diagnostic] = []
115
+ consumed: set[int] = set()
116
+ for node in nodes(tree, ast.Constant, ast.BinOp, ast.JoinedStr):
117
+ if id(node) in consumed:
118
+ continue
119
+ if isinstance(node, (ast.BinOp, ast.JoinedStr)):
120
+ consumed.update(id(child) for child in walk(node))
121
+ if not _is_fully_static_string(node):
122
+ continue
123
+ text = sql_string_value(node)
124
+ if text is None:
125
+ continue
126
+ sql = strip_sql_noise(text)
127
+ if _QUERY_SHAPE.search(sql) is None or not _created_at_ends_order_clause(sql):
128
+ continue
129
+ diagnostics.append(
130
+ Diagnostic(
131
+ path=path,
132
+ line=node.lineno,
133
+ col=node.col_offset + 1,
134
+ code=self.code,
135
+ severity=Severity.WARNING,
136
+ message=(
137
+ "Store query leaves `created_at` as its final ordering item; add a stable tie-break key "
138
+ "after it, such as `id` (for example, `ORDER BY created_at DESC, id DESC`)."
139
+ ),
140
+ )
141
+ )
142
+ diagnostics.sort(key=lambda diagnostic: (diagnostic.line, diagnostic.col))
143
+ return diagnostics
144
+
145
+
146
+ def _is_fully_static_string(node: ast.expr) -> bool:
147
+ if isinstance(node, ast.Constant):
148
+ return isinstance(node.value, str)
149
+ return (
150
+ isinstance(node, ast.BinOp)
151
+ and isinstance(node.op, ast.Add)
152
+ and _is_fully_static_string(node.left)
153
+ and _is_fully_static_string(node.right)
154
+ )
155
+
156
+
157
+ def _created_at_ends_order_clause(sql: str) -> bool:
158
+ depths = _depths(sql)
159
+ for order in _ORDER_BY.finditer(sql):
160
+ clause_depth = depths[order.start()]
161
+ item_start = order.end()
162
+ clause_end = len(sql)
163
+ index = item_start
164
+ while index < len(sql):
165
+ if (
166
+ depths[index] < clause_depth
167
+ or sql[index] == ";"
168
+ or (sql[index] == ")" and depths[index] == clause_depth)
169
+ ):
170
+ clause_end = index
171
+ break
172
+ if depths[index] == clause_depth:
173
+ if sql[index] == ",":
174
+ item_start = index + 1
175
+ elif _CLAUSE_BOUNDARY.match(sql, index) is not None:
176
+ clause_end = index
177
+ break
178
+ index += 1
179
+ if _CREATED_AT_ITEM.fullmatch(sql[item_start:clause_end].strip()) is not None:
180
+ return True
181
+ return False
182
+
183
+
184
+ def _depths(sql: str) -> list[int]:
185
+ depths: list[int] = []
186
+ depth = 0
187
+ for character in sql:
188
+ depths.append(depth)
189
+ if character == "(":
190
+ depth += 1
191
+ elif character == ")" and depth:
192
+ depth -= 1
193
+ return depths
@@ -0,0 +1,120 @@
1
+ """SARJ405 — APIRouter collection roots use an empty path, not ``/``.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_apirouter_root_trailing_slash.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import PurePosixPath
10
+ from typing import TYPE_CHECKING, ClassVar, final, override
11
+
12
+ from sarj_python_lint.rule_base import (
13
+ AutofixPolicy,
14
+ Diagnostic,
15
+ ExampleFile,
16
+ ExampleOutcome,
17
+ Rule,
18
+ RuleCategory,
19
+ RuleDocumentation,
20
+ RuleExample,
21
+ Severity,
22
+ parse_or_none,
23
+ )
24
+ from sarj_python_lint.rules._ast_index import nodes
25
+ from sarj_python_lint.rules._fastapi import FastapiIndex
26
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
27
+
28
+
29
+ if TYPE_CHECKING:
30
+ from pathlib import Path
31
+
32
+
33
+ _DOCUMENTATION_EXAMPLE_DIR_NAMES = frozenset({"docs_src"})
34
+
35
+
36
+ @final
37
+ class NoApirouterRootTrailingSlash(Rule):
38
+ id: str = "no-apirouter-root-trailing-slash"
39
+ code: str = "SARJ405"
40
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
41
+ summary="APIRouter collection-root operations should use an empty path instead of `/`.",
42
+ rationale=(
43
+ "A `/` path beneath an APIRouter prefix makes the prefix without its trailing slash redirect, "
44
+ "rather than serving the canonical collection URL directly."
45
+ ),
46
+ remediation="Declare the APIRouter operation path as an empty string so the prefix itself is canonical.",
47
+ category=RuleCategory.CORRECTNESS,
48
+ autofix=AutofixPolicy.NONE,
49
+ limitations=(
50
+ "Only FastAPI and APIRouter bindings whose constructor provenance is visible in one module are resolved.",
51
+ "Dynamic route paths are ignored because their runtime value cannot be proven.",
52
+ "Tests, generated sources, and FastAPI documentation-source examples are excluded.",
53
+ ),
54
+ examples=(
55
+ RuleExample(
56
+ example_id="router-root-trailing-slash",
57
+ title="APIRouter root operation adds a trailing slash",
58
+ outcome=ExampleOutcome.MATCH,
59
+ files=(
60
+ ExampleFile.python(
61
+ "api.py",
62
+ "from fastapi import APIRouter\n\nrouter = APIRouter(prefix='/items')\n\n"
63
+ "@router.get('/')\nasync def list_items() -> list[str]:\n return []\n",
64
+ ),
65
+ ),
66
+ focus_path=PurePosixPath("api.py"),
67
+ expected_count=1,
68
+ public=True,
69
+ ),
70
+ RuleExample(
71
+ example_id="canonical-router-root",
72
+ title="APIRouter root operation uses its prefix directly",
73
+ outcome=ExampleOutcome.NO_MATCH,
74
+ files=(
75
+ ExampleFile.python(
76
+ "api.py",
77
+ "from fastapi import APIRouter\n\nrouter = APIRouter(prefix='/items')\n\n"
78
+ "@router.get('')\nasync def list_items() -> list[str]:\n return []\n",
79
+ ),
80
+ ),
81
+ focus_path=PurePosixPath("api.py"),
82
+ expected_count=0,
83
+ public=True,
84
+ ),
85
+ ),
86
+ )
87
+ description: str = documentation.summary
88
+
89
+ @override
90
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
91
+ if (
92
+ is_test_path(path)
93
+ or is_generated(path, source)
94
+ or any(part.lower() in _DOCUMENTATION_EXAMPLE_DIR_NAMES for part in path.parts)
95
+ ):
96
+ return []
97
+ tree = parse_or_none(path, source)
98
+ if tree is None:
99
+ return []
100
+ index = FastapiIndex(tree, path=path)
101
+ decorators: dict[int, ast.Call] = {}
102
+ for function in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef):
103
+ for route in index.routes(function):
104
+ if route.receiver_kind == "APIRouter" and route.path == "/":
105
+ decorators[id(route.decorator)] = route.decorator
106
+
107
+ return [
108
+ Diagnostic(
109
+ path=path,
110
+ line=decorator.lineno,
111
+ col=decorator.col_offset + 1,
112
+ code=self.code,
113
+ severity=Severity.WARNING,
114
+ message=(
115
+ "APIRouter collection-root path is `/`, so the prefix without a slash redirects — "
116
+ "declare the path as an empty string. Suppress with `# sarj-noqa: SARJ405`."
117
+ ),
118
+ )
119
+ for decorator in sorted(decorators.values(), key=lambda node: (node.lineno, node.col_offset))
120
+ ]
@@ -0,0 +1,175 @@
1
+ """SARJ404 — Classify unique violations by structured constraint identity.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_unique_violation_message_match.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import PurePosixPath
10
+ from typing import TYPE_CHECKING, ClassVar, final, override
11
+
12
+ from sarj_python_lint.rule_base import (
13
+ AutofixPolicy,
14
+ Diagnostic,
15
+ ExampleFile,
16
+ ExampleOutcome,
17
+ Rule,
18
+ RuleCategory,
19
+ RuleDocumentation,
20
+ RuleExample,
21
+ Severity,
22
+ parse_or_none,
23
+ )
24
+ from sarj_python_lint.rules._imports import ImportIndex
25
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
26
+
27
+
28
+ if TYPE_CHECKING:
29
+ from pathlib import Path
30
+
31
+
32
+ _DRIVERS = frozenset({"psycopg", "psycopg2"})
33
+
34
+
35
+ @final
36
+ class NoUniqueViolationMessageMatch(Rule):
37
+ id = "no-unique-violation-message-match"
38
+ code = "SARJ404"
39
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
40
+ summary="Do not identify a database unique constraint by substring-matching an exception message.",
41
+ rationale=(
42
+ "Database error text is not a stable interface and can change with driver or server versions; "
43
+ "psycopg exposes the violated constraint as structured diagnostic data."
44
+ ),
45
+ remediation="Compare `exc.diag.constraint_name` with the expected constraint name.",
46
+ category=RuleCategory.CORRECTNESS,
47
+ autofix=AutofixPolicy.NONE,
48
+ limitations=(
49
+ "The rule recognizes psycopg and psycopg2 `UniqueViolation` handlers with direct `in` or `not in` checks against `str(exc)`.",
50
+ "Aliases of the stringified exception and other message operations remain outside its scope.",
51
+ "Tests and generated files are excluded.",
52
+ ),
53
+ examples=(
54
+ RuleExample(
55
+ example_id="message-matched-constraint",
56
+ title="Unique constraint selected from error text",
57
+ outcome=ExampleOutcome.MATCH,
58
+ files=(
59
+ ExampleFile.python(
60
+ "app/store.py",
61
+ "from psycopg import errors\n\ntry:\n save()\nexcept errors.UniqueViolation as exc:\n if 'user_email_key' in str(exc):\n raise DuplicateEmail from exc\n raise\n",
62
+ ),
63
+ ),
64
+ focus_path=PurePosixPath("app/store.py"),
65
+ expected_count=1,
66
+ public=True,
67
+ ),
68
+ RuleExample(
69
+ example_id="structured-constraint-check",
70
+ title="Unique constraint selected from structured diagnostics",
71
+ outcome=ExampleOutcome.NO_MATCH,
72
+ files=(
73
+ ExampleFile.python(
74
+ "app/store.py",
75
+ "from psycopg import errors\n\ntry:\n save()\nexcept errors.UniqueViolation as exc:\n if exc.diag.constraint_name == 'user_email_key':\n raise DuplicateEmail from exc\n raise\n",
76
+ ),
77
+ ),
78
+ focus_path=PurePosixPath("app/store.py"),
79
+ expected_count=0,
80
+ public=True,
81
+ ),
82
+ ),
83
+ )
84
+ description = documentation.summary
85
+
86
+ @override
87
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
88
+ if is_test_path(path) or is_generated(path, source) or "UniqueViolation" not in source:
89
+ return []
90
+ tree = parse_or_none(path, source)
91
+ if tree is None:
92
+ return []
93
+ imports = ImportIndex.from_tree(tree)
94
+ if not imports.builtin_is_unshadowed("str"):
95
+ return []
96
+ findings: list[ast.Call] = []
97
+ for handler in (node for node in ast.walk(tree) if isinstance(node, ast.ExceptHandler)):
98
+ if handler.name is None or not _catches_unique_violation(handler.type, tree):
99
+ continue
100
+ for statement in handler.body:
101
+ _collect_message_matches(statement, handler.name, findings)
102
+ findings.sort(key=lambda node: (node.lineno, node.col_offset))
103
+ return [
104
+ Diagnostic(
105
+ path=path,
106
+ line=node.lineno,
107
+ col=node.col_offset + 1,
108
+ code=self.code,
109
+ message=(
110
+ "UniqueViolation is classified by substring-matching its message; compare "
111
+ "`exc.diag.constraint_name` with the expected constraint instead."
112
+ ),
113
+ severity=Severity.WARNING,
114
+ )
115
+ for node in findings
116
+ ]
117
+
118
+
119
+ def _catches_unique_violation(node: ast.expr | None, tree: ast.Module) -> bool:
120
+ if node is None:
121
+ return False
122
+ candidates = node.elts if isinstance(node, ast.Tuple) else (node,)
123
+ return any(
124
+ _qualified_import(candidate, tree) in {"psycopg.errors.UniqueViolation", "psycopg2.errors.UniqueViolation"}
125
+ for candidate in candidates
126
+ )
127
+
128
+
129
+ def _qualified_import(node: ast.expr, tree: ast.Module) -> str | None:
130
+ parts: list[str] = []
131
+ current = node
132
+ while isinstance(current, ast.Attribute):
133
+ parts.append(current.attr)
134
+ current = current.value
135
+ if not isinstance(current, ast.Name):
136
+ return None
137
+ parts.reverse()
138
+ for statement in tree.body:
139
+ if isinstance(statement, ast.Import):
140
+ for alias in statement.names:
141
+ local = alias.asname or alias.name.split(".")[0]
142
+ if local == current.id:
143
+ base = alias.name if alias.asname else alias.name.split(".")[0]
144
+ return ".".join((base, *parts))
145
+ elif isinstance(statement, ast.ImportFrom) and statement.level == 0 and statement.module:
146
+ for alias in statement.names:
147
+ if (alias.asname or alias.name) != current.id:
148
+ continue
149
+ return ".".join((statement.module, alias.name, *parts))
150
+ return None
151
+
152
+
153
+ def _collect_message_matches(node: ast.AST, exception_name: str, findings: list[ast.Call]) -> None:
154
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
155
+ return
156
+ if isinstance(node, ast.Compare) and any(isinstance(operator, (ast.In, ast.NotIn)) for operator in node.ops):
157
+ findings.extend(
158
+ operand
159
+ for operand in (node.left, *node.comparators)
160
+ if isinstance(operand, ast.Call) and _is_str_exception_call(operand, exception_name)
161
+ )
162
+ for child in ast.iter_child_nodes(node):
163
+ _collect_message_matches(child, exception_name, findings)
164
+
165
+
166
+ def _is_str_exception_call(node: ast.expr, exception_name: str) -> bool:
167
+ return (
168
+ isinstance(node, ast.Call)
169
+ and isinstance(node.func, ast.Name)
170
+ and node.func.id == "str"
171
+ and len(node.args) == 1
172
+ and not node.keywords
173
+ and isinstance(node.args[0], ast.Name)
174
+ and node.args[0].id == exception_name
175
+ )