sarj-python-lint 0.80.10__tar.gz → 0.80.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 (124) hide show
  1. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_registry.py +0 -2
  4. sarj_python_lint-0.80.12/src/sarj_python_lint/rules/trailing_value_narration.py +295 -0
  5. sarj_python_lint-0.80.10/src/sarj_python_lint/rules/trailing_value_narration.py +0 -197
  6. sarj_python_lint-0.80.10/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -466
  7. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/.gitignore +0 -0
  8. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/LICENSE +0 -0
  9. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/README.md +0 -0
  10. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_analysis_session.py +0 -0
  13. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  27. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_imports.py +0 -0
  28. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_project_index.py +0 -0
  31. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  37. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  38. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  41. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  42. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  43. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  47. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  48. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  49. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  50. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  51. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  53. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  54. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  55. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  56. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  57. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  58. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  59. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  60. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  61. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  62. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  63. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  64. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  65. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  66. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  67. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  68. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  69. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  70. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  71. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  72. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  73. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  74. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  75. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  76. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  77. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  78. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  79. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  80. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  81. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  82. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  83. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  84. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  85. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  86. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  87. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  88. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  89. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  90. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  91. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  92. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  93. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  94. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  95. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  96. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  97. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  98. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  99. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  100. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  101. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  102. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  103. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  104. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  105. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  106. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  107. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  108. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  109. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  110. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  111. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  112. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  113. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  114. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  115. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  116. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  117. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  118. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/stepdown.py +0 -0
  119. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  120. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  121. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  122. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  123. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  124. {sarj_python_lint-0.80.10 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.80.10
3
+ Version: 0.80.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.80.10"
3
+ version = "0.80.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" }]
@@ -146,7 +146,6 @@ from sarj_python_lint.rules.store_insert_requires_on_conflict import (
146
146
  )
147
147
  from sarj_python_lint.rules.timestamp_order_requires_tiebreaker import TimestampOrderRequiresTiebreaker
148
148
  from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarration
149
- from sarj_python_lint.rules.trivially_true_assertion import TriviallyTrueAssertion
150
149
  from sarj_python_lint.rules.typed_error_reasons import TypedErrorReasons
151
150
  from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
152
151
  from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
@@ -211,7 +210,6 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
211
210
  OverMockedTest.id: OverMockedTest,
212
211
  InvalidPydanticFieldDefault.id: InvalidPydanticFieldDefault,
213
212
  NoFrozenAfterValidatorFieldWrite.id: NoFrozenAfterValidatorFieldWrite,
214
- TriviallyTrueAssertion.id: TriviallyTrueAssertion,
215
213
  NoRepeatedTestBody.id: NoRepeatedTestBody,
216
214
  UnusedMockSetup.id: UnusedMockSetup,
217
215
  PreferFstringOverConcat.id: PreferFstringOverConcat,
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ import re
6
+ import tokenize
7
+ from typing import TYPE_CHECKING, ClassVar, override
8
+
9
+ from sarj_python_lint.rule_base import (
10
+ AutofixPolicy,
11
+ ColumnEncoding,
12
+ Diagnostic,
13
+ ExampleFile,
14
+ ExampleOutcome,
15
+ Rule,
16
+ RuleCategory,
17
+ RuleDocumentation,
18
+ RuleExample,
19
+ Severity,
20
+ parse_or_none,
21
+ )
22
+ from sarj_python_lint.rules._comments import (
23
+ code_tokens,
24
+ has_external_reference,
25
+ nested_comment_lines,
26
+ stem,
27
+ trailing_comments,
28
+ )
29
+ from sarj_python_lint.rules._paths import is_generated
30
+
31
+
32
+ if TYPE_CHECKING:
33
+ from pathlib import Path
34
+
35
+
36
+ # A number that is not part of an identifier or a dotted attribute path.
37
+ _NUMBER_RE = re.compile(
38
+ r"(?<![\w.])(?:0[xob][0-9a-f_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?)(?![\d_.])",
39
+ re.IGNORECASE,
40
+ )
41
+ _WORD_RE = re.compile(r"[A-Za-z]+(?:'[a-z]+)?|\d[\d_]*(?:\.\d[\d_]*)?")
42
+
43
+ # Words that name the unit rather than the quantity — the one thing the code
44
+ # does not say, and the reason the fix is a *name*, not a deletion.
45
+ _UNIT_WORDS = frozenset(
46
+ {
47
+ "bytes",
48
+ "byte",
49
+ "characters",
50
+ "character",
51
+ "chars",
52
+ "day",
53
+ "days",
54
+ "gb",
55
+ "gib",
56
+ "hour",
57
+ "hours",
58
+ "hr",
59
+ "hrs",
60
+ "hz",
61
+ "items",
62
+ "item",
63
+ "k",
64
+ "kb",
65
+ "kib",
66
+ "khz",
67
+ "m",
68
+ "mb",
69
+ "mib",
70
+ "microseconds",
71
+ "milliseconds",
72
+ "min",
73
+ "mins",
74
+ "minute",
75
+ "minutes",
76
+ "ms",
77
+ "pct",
78
+ "percent",
79
+ "px",
80
+ "retries",
81
+ "retry",
82
+ "rows",
83
+ "row",
84
+ "s",
85
+ "sec",
86
+ "second",
87
+ "seconds",
88
+ "secs",
89
+ "times",
90
+ "tokens",
91
+ "token",
92
+ "us",
93
+ "ns",
94
+ "week",
95
+ "weeks",
96
+ }
97
+ )
98
+
99
+ _STOPWORDS = frozenset(
100
+ {
101
+ "a",
102
+ "an",
103
+ "and",
104
+ "are",
105
+ "as",
106
+ "at",
107
+ "be",
108
+ "by",
109
+ "for",
110
+ "in",
111
+ "is",
112
+ "it",
113
+ "of",
114
+ "on",
115
+ "or",
116
+ "that",
117
+ "the",
118
+ "this",
119
+ "we",
120
+ "with",
121
+ }
122
+ )
123
+
124
+ _DIRECTIVE_RE = re.compile(
125
+ r"^\s*(?:todo|fixme|hack\b|xxx|noqa|sarj-noqa|type:|pragma|pyright|mypy|fmt:|isort|ruff|"
126
+ r"pylint|flake8|nosec|nosemgrep)",
127
+ re.IGNORECASE,
128
+ )
129
+
130
+
131
+ def _narrates_value(body: str, code: str, code_numbers: frozenset[str]) -> bool:
132
+ if not body or _DIRECTIVE_RE.match(body) or has_external_reference(body):
133
+ return False
134
+ if not code_numbers:
135
+ return False
136
+ words = [match.group(0).lower() for match in _WORD_RE.finditer(body)]
137
+ if not words:
138
+ return False
139
+ comment_number_texts = [match.group(0) for match in _NUMBER_RE.finditer(body)]
140
+ if len(comment_number_texts) != 1:
141
+ return False
142
+ comment_number = _number_key(comment_number_texts[0])
143
+ if comment_number is None or comment_number not in code_numbers:
144
+ return False
145
+ if not any(word in _UNIT_WORDS for word in words):
146
+ return False
147
+ identifiers = code_tokens(code)
148
+ identifier_stems = {stem(token) for token in identifiers}
149
+ for word in words:
150
+ if word in _STOPWORDS or word in _UNIT_WORDS or _number_key(word) == comment_number:
151
+ continue
152
+ if word in identifiers or stem(word) in identifier_stems:
153
+ continue
154
+ return False
155
+ return True
156
+
157
+
158
+ class TrailingValueNarration(Rule):
159
+ id: str = "no-trailing-numeric-unit-assignment-comment"
160
+ code: str = "SARJ051"
161
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
162
+ summary="A trailing comment redundantly labels a numeric assignment with its value and unit.",
163
+ rationale=(
164
+ "A unit encoded only in a comment can drift; a unit-bearing name makes it machine- and "
165
+ "reviewer-visible, while a duration type can enforce it."
166
+ ),
167
+ remediation=(
168
+ "Encode or reconcile the unit in the assigned name or typed value, such as timeout_ms or "
169
+ "timedelta(minutes=5), then delete the duplicate comment."
170
+ ),
171
+ category=RuleCategory.MAINTAINABILITY,
172
+ autofix=AutofixPolicy.SUGGESTION,
173
+ aliases=("trailing-value-narration",),
174
+ limitations=(
175
+ "Detection targets one-line numeric assignments with one simple name or attribute target and a trailing comment that repeats one number and unit.",
176
+ "Calls, returns, loops, assertions, chained or destructured assignments, multi-number comments, approximate conversions, reasons, references, directives, bracketed values, invalid syntax, and generated files are excluded.",
177
+ ),
178
+ examples=(
179
+ RuleExample(
180
+ example_id="repeated-numeric-unit",
181
+ title="Comment repeats the duration",
182
+ outcome=ExampleOutcome.MATCH,
183
+ files=(ExampleFile.python("settings.py", "STALE_TIME = 5 * 60 * 1000 # 5 minutes\n"),),
184
+ focus_path=PurePosixPath("settings.py"),
185
+ expected_count=1,
186
+ public=True,
187
+ ),
188
+ RuleExample(
189
+ example_id="unit-encoded-in-value",
190
+ title="Typed value carries the unit",
191
+ outcome=ExampleOutcome.NO_MATCH,
192
+ files=(
193
+ ExampleFile.python(
194
+ "settings.py",
195
+ "from datetime import timedelta\n\nSTALE_TIME = timedelta(minutes=5)\n",
196
+ ),
197
+ ),
198
+ focus_path=PurePosixPath("settings.py"),
199
+ expected_count=0,
200
+ public=True,
201
+ ),
202
+ ),
203
+ )
204
+ description: str = documentation.summary
205
+
206
+ @override
207
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
208
+ if is_generated(path, source):
209
+ return []
210
+ try:
211
+ trailing = trailing_comments(source)
212
+ nested = nested_comment_lines(source)
213
+ except tokenize.TokenError, IndentationError, SyntaxError:
214
+ return []
215
+ if not trailing:
216
+ return []
217
+ tree = parse_or_none(path, source)
218
+ if tree is None:
219
+ return []
220
+ lines = source.splitlines()
221
+ assignments = _numeric_assignments(tree)
222
+ diags: list[Diagnostic] = []
223
+ for line, col, body in trailing:
224
+ if line > len(lines) or line in nested:
225
+ continue
226
+ if (numbers := assignments.get(line)) is not None and _narrates_value(body, lines[line - 1][:col], numbers):
227
+ diags.append(
228
+ Diagnostic(
229
+ path=path,
230
+ line=line,
231
+ col=col + 1,
232
+ code=self.code,
233
+ message=(
234
+ f"Trailing numeric-unit label {body.strip()!r} can drift; encode or reconcile the "
235
+ "unit in the assigned name or value type, then remove the comment."
236
+ ),
237
+ severity=Severity.WARNING,
238
+ column_encoding=ColumnEncoding.CODEPOINTS,
239
+ )
240
+ )
241
+ return diags
242
+
243
+
244
+ def _numeric_assignments(tree: ast.AST) -> dict[int, frozenset[str]]:
245
+ statements_per_line: dict[int, int] = {}
246
+ candidates: dict[int, list[frozenset[str]]] = {}
247
+ for statement in ast.walk(tree):
248
+ if not isinstance(statement, ast.stmt):
249
+ continue
250
+ statements_per_line[statement.lineno] = statements_per_line.get(statement.lineno, 0) + 1
251
+ if not isinstance(statement, (ast.Assign, ast.AnnAssign)) or statement.end_lineno != statement.lineno:
252
+ continue
253
+ targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
254
+ if len(targets) != 1 or not isinstance(targets[0], (ast.Name, ast.Attribute)):
255
+ continue
256
+ value = statement.value
257
+ if value is None or (numbers := _numeric_expression_numbers(value)) is None or not numbers:
258
+ continue
259
+ candidates.setdefault(statement.lineno, []).append(numbers)
260
+ return {
261
+ line: values[0]
262
+ for line, values in candidates.items()
263
+ if len(values) == 1 and statements_per_line.get(line) == 1
264
+ }
265
+
266
+
267
+ def _numeric_expression_numbers(node: ast.expr) -> frozenset[str] | None:
268
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
269
+ key = _number_key(node.value)
270
+ return frozenset() if key is None else frozenset({key})
271
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
272
+ return _numeric_expression_numbers(node.operand)
273
+ if isinstance(node, ast.BinOp) and isinstance(
274
+ node.op,
275
+ (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow),
276
+ ):
277
+ left = _numeric_expression_numbers(node.left)
278
+ right = _numeric_expression_numbers(node.right)
279
+ return None if left is None or right is None else left | right
280
+ return None
281
+
282
+
283
+ def _number_key(value: str | float) -> str | None:
284
+ parsed: object = value
285
+ if isinstance(value, str):
286
+ normalized = value.replace("_", "")
287
+ try:
288
+ parsed = float(normalized) if "." in normalized or "e" in normalized.lower() else int(normalized, 0)
289
+ except ValueError:
290
+ return None
291
+ if isinstance(parsed, bool) or not isinstance(parsed, (int, float)):
292
+ return None
293
+ if isinstance(parsed, float) and parsed.is_integer():
294
+ return str(int(parsed))
295
+ return str(parsed)
@@ -1,197 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import PurePosixPath
4
- import re
5
- import tokenize
6
- from typing import TYPE_CHECKING, ClassVar, override
7
-
8
- from sarj_python_lint.rule_base import (
9
- AutofixPolicy,
10
- ColumnEncoding,
11
- Diagnostic,
12
- ExampleFile,
13
- ExampleOutcome,
14
- Rule,
15
- RuleCategory,
16
- RuleDocumentation,
17
- RuleExample,
18
- )
19
- from sarj_python_lint.rules._comments import (
20
- code_tokens,
21
- has_external_reference,
22
- nested_comment_lines,
23
- stem,
24
- trailing_comments,
25
- )
26
- from sarj_python_lint.rules._paths import is_generated
27
-
28
-
29
- if TYPE_CHECKING:
30
- from pathlib import Path
31
-
32
-
33
- # A number that is not part of an identifier or a dotted attribute path.
34
- _NUMBER_RE = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?)(?![\w.])")
35
- _WORD_RE = re.compile(r"[A-Za-z]+(?:'[a-z]+)?|\d+(?:\.\d+)?")
36
-
37
- # Words that name the unit rather than the quantity — the one thing the code
38
- # does not say, and the reason the fix is a *name*, not a deletion.
39
- _UNIT_WORDS = frozenset(
40
- {
41
- "bytes",
42
- "characters",
43
- "chars",
44
- "day",
45
- "days",
46
- "gb",
47
- "hour",
48
- "hours",
49
- "hr",
50
- "hrs",
51
- "hz",
52
- "items",
53
- "k",
54
- "kb",
55
- "khz",
56
- "m",
57
- "mb",
58
- "milliseconds",
59
- "min",
60
- "mins",
61
- "minute",
62
- "minutes",
63
- "ms",
64
- "pct",
65
- "percent",
66
- "px",
67
- "retries",
68
- "rows",
69
- "s",
70
- "sec",
71
- "second",
72
- "seconds",
73
- "secs",
74
- "times",
75
- "tokens",
76
- }
77
- )
78
-
79
- _STOPWORDS = frozenset(
80
- {
81
- "a",
82
- "an",
83
- "and",
84
- "are",
85
- "as",
86
- "at",
87
- "be",
88
- "by",
89
- "for",
90
- "in",
91
- "is",
92
- "it",
93
- "of",
94
- "on",
95
- "or",
96
- "that",
97
- "the",
98
- "this",
99
- "we",
100
- "with",
101
- }
102
- )
103
-
104
- _DIRECTIVE_RE = re.compile(
105
- r"^\s*(?:todo|fixme|hack\b|xxx|noqa|sarj-noqa|type:|pragma|pyright|mypy|fmt:|isort|ruff|"
106
- r"pylint|flake8|nosec|nosemgrep)",
107
- re.IGNORECASE,
108
- )
109
-
110
-
111
- def _narrates_value(body: str, code: str) -> bool:
112
- if not body or _DIRECTIVE_RE.match(body) or has_external_reference(body):
113
- return False
114
- code_numbers = {match.group(0) for match in _NUMBER_RE.finditer(code)}
115
- if not code_numbers:
116
- return False
117
- words = [match.group(0).lower() for match in _WORD_RE.finditer(body)]
118
- if not words:
119
- return False
120
- comment_numbers = {match.group(0) for match in _NUMBER_RE.finditer(body)}
121
- if not comment_numbers or not comment_numbers <= code_numbers:
122
- return False
123
- if not any(word in _UNIT_WORDS for word in words):
124
- return False
125
- identifiers = code_tokens(code)
126
- identifier_stems = {stem(token) for token in identifiers}
127
- for word in words:
128
- if word in _STOPWORDS or word in _UNIT_WORDS or word in comment_numbers:
129
- continue
130
- if word in identifiers or stem(word) in identifier_stems:
131
- continue
132
- return False
133
- return True
134
-
135
-
136
- class TrailingValueNarration(Rule):
137
- id: str = "trailing-value-narration"
138
- code: str = "SARJ051"
139
- documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
140
- summary="Trailing comment restates a literal value and its unit.",
141
- rationale="A unit encoded only in a comment can drift from the value and is unavailable to type checking.",
142
- remediation="Encode the unit in the name or value type, such as timeout_seconds or timedelta.",
143
- category=RuleCategory.MAINTAINABILITY,
144
- autofix=AutofixPolicy.SUGGESTION,
145
- limitations=(
146
- "Detection targets simple numeric assignments with a trailing comment that repeats the number and unit.",
147
- "Approximate conversions, reasons, references, directives, bracketed values, and generated files are excluded.",
148
- ),
149
- examples=(
150
- RuleExample(
151
- example_id="repeated-duration",
152
- title="Comment repeats the duration",
153
- outcome=ExampleOutcome.MATCH,
154
- files=(ExampleFile.python("settings.py", "STALE_TIME = 5 * 60 * 1000 # 5 minutes\n"),),
155
- focus_path=PurePosixPath("settings.py"),
156
- expected_count=1,
157
- public=True,
158
- ),
159
- RuleExample(
160
- example_id="comment-explains-policy",
161
- title="Comment explains a policy",
162
- outcome=ExampleOutcome.NO_MATCH,
163
- files=(ExampleFile.python("settings.py", "BACKOFF = 2 * 60 # doubles per attempt\n"),),
164
- focus_path=PurePosixPath("settings.py"),
165
- expected_count=0,
166
- public=True,
167
- ),
168
- ),
169
- )
170
- description: str = documentation.summary
171
-
172
- @override
173
- def check(self, path: Path, source: str) -> list[Diagnostic]:
174
- if is_generated(path, source):
175
- return []
176
- try:
177
- trailing = trailing_comments(source)
178
- nested = nested_comment_lines(source)
179
- except tokenize.TokenError, IndentationError, SyntaxError:
180
- return []
181
- lines = source.splitlines()
182
- diags: list[Diagnostic] = []
183
- for line, col, body in trailing:
184
- if line > len(lines) or line in nested:
185
- continue
186
- if _narrates_value(body, lines[line - 1][:col]):
187
- diags.append(
188
- Diagnostic(
189
- path=path,
190
- line=line,
191
- col=col + 1,
192
- code=self.code,
193
- message=self.description,
194
- column_encoding=ColumnEncoding.CODEPOINTS,
195
- )
196
- )
197
- return diags