sarj-python-lint 0.72.0__tar.gz → 0.73.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 (116) hide show
  1. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__init__.py +0 -2
  4. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__main__.py +27 -21
  5. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_filesystem.py +0 -3
  6. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_ratchet_cli.py +0 -5
  7. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_secret_names.py +0 -6
  8. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_version.py +0 -2
  9. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/ratchet.py +10 -26
  10. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rule_base.py +0 -34
  11. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_ast_index.py +0 -8
  12. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_comments.py +25 -30
  13. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_docstrings.py +77 -13
  14. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_fastapi.py +5 -21
  15. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_first_party.py +0 -11
  16. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_imports.py +0 -7
  17. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_logging.py +0 -3
  18. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_paths.py +0 -11
  19. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_project_index.py +62 -20
  20. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -12
  21. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_pytest.py +0 -4
  22. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_registry.py +6 -0
  23. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_sql.py +0 -6
  24. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -6
  25. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -4
  26. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -5
  27. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -11
  28. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -5
  29. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -7
  30. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicate_test_body.py +20 -40
  31. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -7
  32. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +11 -11
  33. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +18 -20
  34. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -5
  35. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -5
  36. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -13
  37. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/mock_without_spec.py +9 -28
  38. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -6
  39. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -6
  40. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_comment_cruft.py +8 -24
  41. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -9
  42. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +10 -14
  43. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -8
  44. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -8
  45. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -12
  46. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -5
  47. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -7
  48. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -9
  49. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -5
  50. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -19
  51. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -6
  52. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -5
  53. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -14
  54. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -6
  55. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -7
  56. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -12
  57. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -11
  58. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_select_star.py +0 -6
  59. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -41
  60. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -12
  61. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -21
  62. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_tautological_expect.py +15 -30
  63. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -5
  64. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -5
  65. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/no_unnecessary_docstring.py +620 -0
  66. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/no_vague_suppression_description.py +107 -0
  67. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +10 -12
  68. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/over_mocked_test.py +26 -53
  69. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -5
  70. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -11
  71. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -16
  72. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -23
  73. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -9
  74. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -21
  75. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -20
  76. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +23 -49
  77. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -33
  78. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +24 -34
  79. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +5 -11
  80. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +9 -15
  81. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -18
  82. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -5
  83. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -10
  84. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_str_enum.py +8 -42
  85. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -6
  86. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +5 -26
  87. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -7
  88. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -10
  89. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -7
  90. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +5 -10
  91. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -5
  92. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +9 -10
  93. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +5 -24
  94. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +4 -46
  95. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -10
  96. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -5
  97. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -6
  98. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -19
  99. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_port_for_service.py +9 -32
  100. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +22 -14
  101. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -5
  102. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_validated_row_factory.py +9 -9
  103. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -8
  104. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/source_coupled_test.py +12 -12
  105. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -5
  106. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/stepdown.py +0 -15
  107. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -7
  108. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -5
  109. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +19 -36
  110. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -8
  111. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -22
  112. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/.gitignore +0 -0
  113. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/LICENSE +0 -0
  114. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/README.md +0 -0
  115. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/py.typed +0 -0
  116. {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.72.0
3
+ Version: 0.73.0
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.72.0"
3
+ version = "0.73.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" }]
@@ -1,5 +1,3 @@
1
- """sarj-python-lint — custom Python lint rules."""
2
-
3
1
  from sarj_python_lint._ratchet_cli import main as run_ratchet
4
2
  from sarj_python_lint._version import __version__
5
3
 
@@ -1,5 +1,3 @@
1
- """CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import argparse
@@ -99,7 +97,11 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
99
97
  diags.extend(
100
98
  diagnostic
101
99
  for diagnostic in deduplicate_diagnostics(
102
- [diagnostic for diagnostic in raw if not is_suppressed(source_lines, diagnostic.line, diagnostic.code)],
100
+ [
101
+ diagnostic
102
+ for diagnostic in raw
103
+ if diagnostic.code == "SARJ419" or not is_suppressed(source_lines, diagnostic.line, diagnostic.code)
104
+ ],
103
105
  source=source,
104
106
  )
105
107
  )
@@ -113,7 +115,6 @@ def analyze(
113
115
  baseline: Path | None = None,
114
116
  root: Path | None = None,
115
117
  ) -> list[Diagnostic]:
116
- """Return native diagnostics without rendering CLI output."""
117
118
  diagnostics = _check(rule_ids, paths)
118
119
  return diagnostics if baseline is None else _apply_baseline(diagnostics, _read_baseline(baseline), root=root)
119
120
 
@@ -121,13 +122,23 @@ def analyze(
121
122
  _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
122
123
  {
123
124
  "SARJ003": frozenset({"SARJ080"}),
124
- "SARJ084": frozenset({"SARJ050", "SARJ091"}),
125
- "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ091"}),
126
- "SARJ092": frozenset({"SARJ086", "SARJ087"}),
125
+ "SARJ050": frozenset({"SARJ420"}),
126
+ "SARJ084": frozenset({"SARJ050", "SARJ091", "SARJ420"}),
127
+ "SARJ085": frozenset({"SARJ420"}),
128
+ "SARJ086": frozenset({"SARJ420"}),
129
+ "SARJ087": frozenset({"SARJ420"}),
130
+ "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ091", "SARJ420"}),
131
+ "SARJ091": frozenset({"SARJ420"}),
132
+ "SARJ092": frozenset({"SARJ086", "SARJ087", "SARJ420"}),
127
133
  "SARJ093": frozenset({"SARJ034"}),
134
+ "SARJ099": frozenset({"SARJ420"}),
128
135
  }
129
136
  )
130
137
 
138
+ _DOCSTRING_PRECEDENCE_CODES = frozenset(
139
+ {"SARJ050", "SARJ084", "SARJ085", "SARJ086", "SARJ087", "SARJ088", "SARJ091", "SARJ092", "SARJ099"}
140
+ )
141
+
131
142
 
132
143
  class _OwnerLocation(NamedTuple):
133
144
  line: int
@@ -135,9 +146,10 @@ class _OwnerLocation(NamedTuple):
135
146
 
136
147
 
137
148
  def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
138
- """Keep the most specific remediation at a source location."""
139
149
  codes = frozenset(diagnostic.code for diagnostic in diags)
140
- needs_docstring_owners = "SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])
150
+ needs_docstring_owners = ("SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])) or (
151
+ "SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
152
+ )
141
153
  docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
142
154
  needs_signature_owners = "SARJ093" in codes and "SARJ034" in codes
143
155
  signature_owners = (
@@ -183,31 +195,29 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
183
195
  ]
184
196
 
185
197
 
186
- def _function_signature_owner_locations(source: str) -> dict[int, tuple[int, int]]:
187
- """Map signature lines to their function opening for cross-rule precedence."""
198
+ def _function_signature_owner_locations(source: str) -> dict[int, _OwnerLocation]:
188
199
  try:
189
200
  tree = ast.parse(source)
190
201
  except SyntaxError:
191
202
  return {}
192
- owners: dict[int, tuple[int, int]] = {}
203
+ owners: dict[int, _OwnerLocation] = {}
193
204
  for node in ast.walk(tree):
194
205
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
195
206
  continue
196
207
  body_line = node.body[0].lineno if node.body else node.lineno + 1
197
208
  end_line = max(node.lineno, body_line - 1)
198
- owners.update(dict.fromkeys(range(node.lineno, end_line + 1), (node.lineno, node.col_offset + 1)))
209
+ owners.update(dict.fromkeys(range(node.lineno, end_line + 1), _OwnerLocation(node.lineno, node.col_offset + 1)))
199
210
  return owners
200
211
 
201
212
 
202
- def _docstring_owner_locations(source: str) -> dict[int, tuple[int, int]]:
203
- """Map every physical docstring line to the opening expression that owns it."""
213
+ def _docstring_owner_locations(source: str) -> dict[int, _OwnerLocation]:
204
214
  try:
205
215
  tree = ast.parse(source)
206
216
  except SyntaxError:
207
217
  tree = None
208
218
  if tree is None:
209
219
  return {}
210
- owners: dict[int, tuple[int, int]] = {}
220
+ owners: dict[int, _OwnerLocation] = {}
211
221
  for node in ast.walk(tree):
212
222
  if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) or not node.body:
213
223
  continue
@@ -218,7 +228,7 @@ def _docstring_owner_locations(source: str) -> dict[int, tuple[int, int]]:
218
228
  and isinstance(expression.value.value, str)
219
229
  ):
220
230
  continue
221
- location = (expression.lineno, expression.col_offset + 1)
231
+ location = _OwnerLocation(expression.lineno, expression.col_offset + 1)
222
232
  lines = range(expression.lineno, (expression.end_lineno or expression.lineno) + 1)
223
233
  owners.update(dict.fromkeys(lines, location))
224
234
  return owners
@@ -245,7 +255,6 @@ class _Args(argparse.Namespace):
245
255
 
246
256
 
247
257
  def _explain(wanted: str) -> int:
248
- """Print a rule's description and its derived examples link."""
249
258
  key = wanted.strip()
250
259
  cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
251
260
  if cls is None:
@@ -267,7 +276,6 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
267
276
 
268
277
 
269
278
  def _baseline_path(path: Path, *, root: Path | None = None) -> str:
270
- """Make baselines portable when a caller supplies repository-absolute paths."""
271
279
  try:
272
280
  return path.resolve().relative_to((Path.cwd() if root is None else root).resolve()).as_posix()
273
281
  except ValueError:
@@ -275,7 +283,6 @@ def _baseline_path(path: Path, *, root: Path | None = None) -> str:
275
283
 
276
284
 
277
285
  def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
278
- """Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries."""
279
286
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
280
287
  path.read_text(encoding="utf-8")
281
288
  )
@@ -299,7 +306,6 @@ def _apply_baseline(
299
306
  *,
300
307
  root: Path | None = None,
301
308
  ) -> list[Diagnostic]:
302
- """Suppress up to the baselined count per (path, code); excess diags survive."""
303
309
  seen: Counter[tuple[str, str]] = Counter()
304
310
  out: list[Diagnostic] = []
305
311
  for d in diags:
@@ -1,5 +1,3 @@
1
- """Small safe filesystem primitives shared by command-line workflows."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import os
@@ -8,7 +6,6 @@ import tempfile
8
6
 
9
7
 
10
8
  def atomic_write_text(path: Path, contents: str) -> None:
11
- """Replace an existing-parent destination atomically."""
12
9
  if not path.parent.is_dir():
13
10
  msg = f"baseline parent does not exist: {path.parent}"
14
11
  raise OSError(msg)
@@ -1,5 +1,3 @@
1
- """CLI: `sarj-ratchet [--baseline PATH] [--package DIR]... [--update [--allow-increase]] [ROOT]`."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import argparse
@@ -50,7 +48,6 @@ class _Args(argparse.Namespace):
50
48
 
51
49
 
52
50
  def main(argv: list[str] | None = None) -> int:
53
- """Run the ratchet."""
54
51
  args = _build_parser().parse_args(argv, namespace=_Args())
55
52
  root = args.root.resolve()
56
53
  baseline_path = args.baseline if args.baseline is not None else root / _DEFAULT_BASELINE_NAME
@@ -128,7 +125,6 @@ def main(argv: list[str] | None = None) -> int:
128
125
 
129
126
 
130
127
  def _build_parser() -> argparse.ArgumentParser:
131
- """Assemble the argument parser."""
132
128
  parser = argparse.ArgumentParser(
133
129
  prog="sarj-ratchet",
134
130
  description=(
@@ -180,7 +176,6 @@ def _update(
180
176
  *,
181
177
  allow_increase: bool,
182
178
  ) -> int:
183
- """Re-seed the baseline, refusing raises unless they were explicitly reviewed."""
184
179
  would_raise = gate(measurement, baseline)
185
180
  if would_raise and not allow_increase:
186
181
  sys.stderr.write("REFUSED: --update would raise ceilings; pass --allow-increase if this was reviewed:\n")
@@ -1,5 +1,3 @@
1
- """Recognize secret-bearing identifiers for SARJ011 and SARJ012."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from itertools import pairwise
@@ -67,7 +65,6 @@ _SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
67
65
 
68
66
 
69
67
  def identifier_tokens(identifier: str) -> list[str]:
70
- """Return lowercase whole segments and their camel-case words."""
71
68
  tokens: list[str] = []
72
69
  for segment in _SEGMENT_RE.split(identifier):
73
70
  if not segment:
@@ -79,7 +76,6 @@ def identifier_tokens(identifier: str) -> list[str]:
79
76
 
80
77
 
81
78
  def leading_word(identifier: str) -> str | None:
82
- """Return the first camel- or delimiter-separated word, lowercased."""
83
79
  for segment in _SEGMENT_RE.split(identifier):
84
80
  if not segment:
85
81
  continue
@@ -89,7 +85,6 @@ def leading_word(identifier: str) -> str | None:
89
85
 
90
86
 
91
87
  def is_secret_name(identifier: str) -> bool:
92
- """Report whether `identifier` names raw secret material (a credential, not metadata)."""
93
88
  tokens = identifier_tokens(identifier)
94
89
  if tokens and tokens[-1] in _INNOCUOUS_WORDS:
95
90
  return False
@@ -101,5 +96,4 @@ def is_secret_name(identifier: str) -> bool:
101
96
 
102
97
 
103
98
  def _has_api_key(tokens: list[str]) -> bool:
104
- """Report whether `api` is immediately followed by `key` (the split form of `api_key`)."""
105
99
  return any(a == "api" and b == "key" for a, b in pairwise(tokens))
@@ -1,5 +1,3 @@
1
- """Resolve the installed package version, with a source-tree fallback."""
2
-
3
1
  from importlib.metadata import PackageNotFoundError, version
4
2
 
5
3
 
@@ -1,5 +1,3 @@
1
- """Suppression ratchet: count every escape hatch in a tree and let the count only shrink."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from collections import Counter
@@ -7,7 +5,7 @@ from dataclasses import dataclass, field
7
5
  import json
8
6
  import re
9
7
  from types import MappingProxyType
10
- from typing import TYPE_CHECKING, Final, TypeGuard
8
+ from typing import TYPE_CHECKING, Final, NamedTuple, TypeGuard
11
9
 
12
10
 
13
11
  if TYPE_CHECKING:
@@ -32,6 +30,12 @@ DEFAULT_EXCLUDED_DIR_NAMES: Final = frozenset(
32
30
  }
33
31
  )
34
32
 
33
+
34
+ class Improvement(NamedTuple):
35
+ previous: int
36
+ current: int
37
+
38
+
35
39
  DEFAULT_PER_FILE_CEILING: Final = 10
36
40
 
37
41
  _NOQA_RE: Final = re.compile(r"#\s*noqa:\s*([A-Z][A-Z0-9]+(?:\s*,\s*[A-Z][A-Z0-9]+)*)")
@@ -50,8 +54,6 @@ _BARE_TYPE_IGNORE_KEY: Final = "type-ignore"
50
54
 
51
55
  @dataclass(frozen=True, slots=True)
52
56
  class Measurement:
53
- """What one scan of the tree found."""
54
-
55
57
  codes: Counter[str]
56
58
  packages: Counter[str]
57
59
  files: dict[str, int]
@@ -64,8 +66,6 @@ class Measurement:
64
66
 
65
67
  @dataclass(frozen=True, slots=True)
66
68
  class Baseline:
67
- """Ceilings by code, package, and file so cleanup in one dimension cannot finance debt in another."""
68
-
69
69
  codes: dict[str, int] = field(default_factory=dict[str, int])
70
70
  packages: dict[str, int] = field(default_factory=dict[str, int])
71
71
  per_file_ceiling: int = DEFAULT_PER_FILE_CEILING
@@ -75,15 +75,12 @@ class Baseline:
75
75
 
76
76
  @dataclass(frozen=True, slots=True)
77
77
  class Failure:
78
- """One ceiling that the measurement exceeded."""
79
-
80
78
  dimension: str
81
79
  key: str
82
80
  ceiling: int
83
81
  actual: int
84
82
 
85
83
  def format(self) -> str:
86
- """Render the failure with the remediation for its dimension."""
87
84
  head = f"FAIL[{self.dimension}] {self.key}: {self.actual} suppressions, ceiling {self.ceiling}."
88
85
  return f"{head} {_REMEDIATION[self.dimension]}"
89
86
 
@@ -115,7 +112,6 @@ def measure(
115
112
  excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
116
113
  excluded_subtrees: Iterable[str] = (),
117
114
  ) -> Measurement:
118
- """Count every suppression under `root`, bucketed by code, package and file."""
119
115
  codes: Counter[str] = Counter()
120
116
  package_counts: Counter[str] = Counter()
121
117
  files: dict[str, int] = {}
@@ -137,7 +133,6 @@ def measure(
137
133
 
138
134
 
139
135
  def count_source(source: str) -> Counter[str]:
140
- """Count the suppressions in one file's text, keyed by dialect and code."""
141
136
  counts: Counter[str] = Counter()
142
137
  for line in source.splitlines():
143
138
  _count_line(line, counts)
@@ -145,7 +140,6 @@ def count_source(source: str) -> Counter[str]:
145
140
 
146
141
 
147
142
  def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
148
- """Compare a measurement against the baseline's three ceilings."""
149
143
  failures = [
150
144
  Failure(dimension="code", key=key, ceiling=c, actual=n)
151
145
  for key, n in sorted(measurement.codes.items())
@@ -164,18 +158,16 @@ def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
164
158
  return failures
165
159
 
166
160
 
167
- def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, tuple[int, int]]:
168
- """Find the code keys now below their ceiling — the wins worth locking in."""
169
- out: dict[str, tuple[int, int]] = {}
161
+ def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, Improvement]:
162
+ out: dict[str, Improvement] = {}
170
163
  for key, ceiling in baseline.codes.items():
171
164
  actual = measurement.codes.get(key, 0)
172
165
  if actual < ceiling:
173
- out[key] = (ceiling, actual)
166
+ out[key] = Improvement(ceiling, actual)
174
167
  return out
175
168
 
176
169
 
177
170
  def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
178
- """Build the baseline that `--update` would write from a measurement."""
179
171
  # Recompute exceptions so a file loses grandfathering as soon as it reaches the ceiling.
180
172
  exceptions = {path: n for path, n in measurement.files.items() if n > baseline.per_file_ceiling}
181
173
  return Baseline(
@@ -188,7 +180,6 @@ def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
188
180
 
189
181
 
190
182
  def load_baseline(path: Path) -> Baseline:
191
- """Read a baseline JSON file, ignoring entries of the wrong shape."""
192
183
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
193
184
  path.read_text(encoding="utf-8")
194
185
  )
@@ -206,14 +197,12 @@ def load_baseline(path: Path) -> Baseline:
206
197
 
207
198
 
208
199
  def _get(mapping: object, key: str) -> object:
209
- """Read one key out of a value that may or may not be a JSON object."""
210
200
  if not isinstance(mapping, dict):
211
201
  return None
212
202
  return mapping.get(key) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] — json leaves are Any
213
203
 
214
204
 
215
205
  def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
216
- """Render a baseline as the JSON text to write."""
217
206
  payload = {
218
207
  "_comment": (
219
208
  "Suppression ceilings, written by `sarj-ratchet --update`. Counts may "
@@ -235,7 +224,6 @@ def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
235
224
 
236
225
 
237
226
  def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES) -> list[str]:
238
- """List the top-level directories under `root` that contain Python files."""
239
227
  return sorted(
240
228
  name
241
229
  for child in root.iterdir()
@@ -247,7 +235,6 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
247
235
 
248
236
 
249
237
  def _int_map(value: object) -> dict[str, int]:
250
- """Narrow a JSON value to `{str: int}`, dropping anything else."""
251
238
  if not isinstance(value, dict):
252
239
  return {}
253
240
  return {
@@ -281,7 +268,6 @@ def _inside_subtree(relative: str, subtree: str) -> bool:
281
268
 
282
269
 
283
270
  def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Path]:
284
- """Yield every `.py` file under `root`, skipping excluded directories."""
285
271
  if not root.is_dir():
286
272
  return
287
273
  for path in sorted(root.rglob("*.py")):
@@ -290,7 +276,6 @@ def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Pa
290
276
 
291
277
 
292
278
  def _read(path: Path) -> str:
293
- """Read a source file, treating undecodable bytes as empty."""
294
279
  try:
295
280
  return path.read_text(encoding="utf-8")
296
281
  except UnicodeDecodeError, OSError:
@@ -298,7 +283,6 @@ def _read(path: Path) -> str:
298
283
 
299
284
 
300
285
  def _count_line(line: str, counts: Counter[str]) -> None:
301
- """Add one line's suppressions to `counts`."""
302
286
  if file_noqa := _FILE_NOQA_RE.match(line):
303
287
  listed = file_noqa.group(1)
304
288
  if listed:
@@ -1,5 +1,3 @@
1
- """Base types for sarj-python-lint rules."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from abc import ABC, abstractmethod
@@ -30,7 +28,6 @@ _SARJ_NOQA_RE = re.compile(
30
28
 
31
29
 
32
30
  def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
33
- """Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment."""
34
31
  if line < 1 or line > len(source_lines):
35
32
  return False
36
33
  text = source_lines[line - 1]
@@ -46,22 +43,16 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
46
43
 
47
44
 
48
45
  class Severity(StrEnum):
49
- """Whether a diagnostic blocks the lint command."""
50
-
51
46
  WARNING = "warning"
52
47
  ERROR = "error"
53
48
 
54
49
 
55
50
  class ColumnEncoding(StrEnum):
56
- """Coordinate system used by a native diagnostic's one-based column."""
57
-
58
51
  UTF8_BYTES = "utf8-bytes"
59
52
  CODEPOINTS = "codepoints"
60
53
 
61
54
 
62
55
  class RuleCategory(StrEnum):
63
- """Small cross-engine taxonomy used by generated rule directories."""
64
-
65
56
  ARCHITECTURE = "architecture"
66
57
  CORRECTNESS = "correctness"
67
58
  MAINTAINABILITY = "maintainability"
@@ -72,16 +63,12 @@ class RuleCategory(StrEnum):
72
63
 
73
64
 
74
65
  class AutofixPolicy(StrEnum):
75
- """Strongest source mutation a rule can safely offer."""
76
-
77
66
  NONE = "none"
78
67
  SUGGESTION = "suggestion"
79
68
  SAFE = "safe"
80
69
 
81
70
 
82
71
  class ExampleOutcome(StrEnum):
83
- """Expected result when a rule checks one documentation example."""
84
-
85
72
  MATCH = "match"
86
73
  NO_MATCH = "no-match"
87
74
 
@@ -94,8 +81,6 @@ type ExamplePath = str
94
81
 
95
82
  @dataclass(frozen=True, slots=True)
96
83
  class ExampleFile:
97
- """One virtual source file in a rule example."""
98
-
99
84
  path: PurePosixPath
100
85
  source: str = field(repr=False)
101
86
 
@@ -109,14 +94,11 @@ class ExampleFile:
109
94
 
110
95
  @classmethod
111
96
  def python(cls, path: ExamplePath, source: str) -> Self:
112
- """Build a Python example file without leaking path parsing into rules."""
113
97
  return cls(PurePosixPath(path), source)
114
98
 
115
99
 
116
100
  @dataclass(frozen=True, slots=True)
117
101
  class RuleExample:
118
- """A reviewed, executable example; examples are private unless opted in."""
119
-
120
102
  example_id: str
121
103
  outcome: ExampleOutcome
122
104
  files: tuple[ExampleFile, ...]
@@ -166,8 +148,6 @@ class RuleExample:
166
148
 
167
149
  @dataclass(frozen=True, slots=True)
168
150
  class RuleDocumentation:
169
- """Source-authored rule prose and reviewed examples."""
170
-
171
151
  summary: str
172
152
  rationale: str
173
153
  remediation: str
@@ -214,8 +194,6 @@ class RuleDocumentation:
214
194
 
215
195
  @dataclass(frozen=True, slots=True)
216
196
  class NativeRuleSpec:
217
- """Complete native rule record adapted from a rule class and its authored docs."""
218
-
219
197
  engine: str
220
198
  rule_id: str
221
199
  code: str
@@ -241,8 +219,6 @@ class NativeRuleSpec:
241
219
 
242
220
  @dataclass(frozen=True, slots=True)
243
221
  class Diagnostic:
244
- """A single lint finding."""
245
-
246
222
  path: Path
247
223
  line: int
248
224
  col: int
@@ -252,14 +228,11 @@ class Diagnostic:
252
228
  column_encoding: ColumnEncoding = ColumnEncoding.UTF8_BYTES
253
229
 
254
230
  def format(self) -> str:
255
- """Render the finding ruff-compatibly as `path:line:col: CODE message`."""
256
231
  label = "warning: " if self.severity is Severity.WARNING else ""
257
232
  return f"{self.path}:{self.line}:{self.col}: {self.code} {label}{self.message}"
258
233
 
259
234
 
260
235
  class Rule(ABC):
261
- """Base class for a single lint rule."""
262
-
263
236
  id: str
264
237
  code: str
265
238
  description: str
@@ -267,7 +240,6 @@ class Rule(ABC):
267
240
 
268
241
  @abstractmethod
269
242
  def check(self, path: Path, source: str) -> list[Diagnostic]:
270
- """Inspect the given source."""
271
243
  raise NotImplementedError
272
244
 
273
245
  @classmethod
@@ -280,7 +252,6 @@ class Rule(ABC):
280
252
 
281
253
  @classmethod
282
254
  def native_spec(cls) -> NativeRuleSpec | None:
283
- """Adapt source-owned documentation while deriving engine, ID, and code."""
284
255
  authored = cls.documentation
285
256
  if authored is None:
286
257
  return None
@@ -306,18 +277,14 @@ class Rule(ABC):
306
277
 
307
278
  @classmethod
308
279
  def public_examples(cls) -> tuple[RuleExample, ...]:
309
- """Return the rule's explicitly publishable canonical fixtures."""
310
280
  spec = cls.native_spec()
311
281
  return () if spec is None else spec.public_examples
312
282
 
313
283
 
314
284
  class ProjectRule(Rule):
315
- """A rule that may resolve first-party symbols prepared once per CLI run."""
316
-
317
285
  _project_indexes: ProjectIndexSet | None = None
318
286
 
319
287
  def prepare(self, indexes: ProjectIndexSet) -> None:
320
- """Attach immutable project symbols before checking the selected files."""
321
288
  self._project_indexes = indexes
322
289
 
323
290
 
@@ -325,7 +292,6 @@ _last_parse: tuple[str, str, ast.Module | None] | None = None
325
292
 
326
293
 
327
294
  def parse_or_none(path: Path, source: str) -> ast.Module | None:
328
- """Parse `source`, memoizing the most recent file so N rules share one parse."""
329
295
  global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
330
296
  path_key = str(path)
331
297
  if _last_parse is not None and _last_parse[0] == path_key and _last_parse[1] is source:
@@ -1,5 +1,3 @@
1
- """One tree traversal per file, shared by every rule that needs node lookups."""
2
-
3
1
  # Breadth-first order and isinstance semantics intentionally match ast.walk because rules rely on first-match order.
4
2
 
5
3
  from __future__ import annotations
@@ -16,7 +14,6 @@ _AST = ast.AST
16
14
 
17
15
 
18
16
  def children(node: ast.AST) -> list[ast.AST]:
19
- """`node`'s direct children, in the same order as `ast.iter_child_nodes`."""
20
17
  out: list[ast.AST] = []
21
18
  for name in node._fields:
22
19
  value: object = getattr(node, name, None)
@@ -28,7 +25,6 @@ def children(node: ast.AST) -> list[ast.AST]:
28
25
 
29
26
 
30
27
  def walk(node: ast.AST) -> Iterator[ast.AST]:
31
- """Yield `node` and every descendant, breadth-first."""
32
28
  queue: list[ast.AST] = [node]
33
29
  i = 0
34
30
  while i < len(queue):
@@ -45,8 +41,6 @@ def walk(node: ast.AST) -> Iterator[ast.AST]:
45
41
 
46
42
  @final
47
43
  class _NodeIndex:
48
- """A module's nodes partitioned by exact class, in breadth-first order."""
49
-
50
44
  __slots__ = ("_buckets", "_flat", "_queries")
51
45
 
52
46
  def __init__(self, tree: ast.AST) -> None:
@@ -73,7 +67,6 @@ class _NodeIndex:
73
67
  self._queries: dict[tuple[type[ast.AST], ...], list[ast.AST]] = {}
74
68
 
75
69
  def query(self, types: tuple[type[ast.AST], ...]) -> list[ast.AST]:
76
- """Return every node matching `isinstance(node, types)`, breadth-first."""
77
70
  hit = self._queries.get(types)
78
71
  if hit is not None:
79
72
  return hit
@@ -98,7 +91,6 @@ _last_index: tuple[ast.AST, _NodeIndex] | None = None
98
91
 
99
92
 
100
93
  def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT]) -> list[NodeT]:
101
- """Index a whole-file tree; use `walk` for subtrees and never mutate indexed nodes."""
102
94
  global _last_index # ruff: ignore[global-statement] — single-slot memo, mirroring `parse_or_none`
103
95
  if _last_index is None or _last_index[0] is not tree:
104
96
  _last_index = (tree, _NodeIndex(tree))