sarj-python-lint 0.81.14__tar.gz → 0.81.16__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 (129) hide show
  1. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_repeated_test_body.py +46 -12
  5. sarj_python_lint-0.81.16/src/sarj_python_lint/rules/unused_test_factory_option.py +206 -0
  6. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/.gitignore +0 -0
  7. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/LICENSE +0 -0
  8. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/README.md +0 -0
  9. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/_analysis_session.py +0 -0
  12. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  27. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_imports.py +0 -0
  28. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_project_index.py +0 -0
  31. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  37. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  38. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  41. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  42. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  43. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  47. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  48. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  49. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  50. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  51. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  53. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  54. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  55. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  56. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  57. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  58. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  59. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  60. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  61. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  62. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  64. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  65. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  66. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  67. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  68. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  69. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  70. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  71. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  72. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  73. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  74. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  75. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  76. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  77. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  78. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  79. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  80. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  81. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  82. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  83. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  84. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  85. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  86. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  87. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  88. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  89. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  90. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  91. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  92. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  94. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  95. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  96. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  97. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  98. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  99. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  100. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  101. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  102. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  103. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  104. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  105. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  106. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  107. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  108. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  109. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  110. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  111. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  112. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  113. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  114. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  115. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  116. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  117. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  118. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  119. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  120. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  121. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  122. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/stepdown.py +0 -0
  123. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  124. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  125. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  126. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  127. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  128. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  129. {sarj_python_lint-0.81.14 → sarj_python_lint-0.81.16}/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.81.14
3
+ Version: 0.81.16
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.81.14"
3
+ version = "0.81.16"
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" }]
@@ -154,6 +154,7 @@ from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarrati
154
154
  from sarj_python_lint.rules.typed_error_reasons import TypedErrorReasons
155
155
  from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
156
156
  from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
157
+ from sarj_python_lint.rules.unused_test_factory_option import UnusedTestFactoryOption
157
158
 
158
159
 
159
160
  if TYPE_CHECKING:
@@ -219,6 +220,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
219
220
  NoFrozenAfterValidatorFieldWrite.id: NoFrozenAfterValidatorFieldWrite,
220
221
  NoRepeatedTestBody.id: NoRepeatedTestBody,
221
222
  UnusedMockSetup.id: UnusedMockSetup,
223
+ UnusedTestFactoryOption.id: UnusedTestFactoryOption,
222
224
  PreferFstringOverConcat.id: PreferFstringOverConcat,
223
225
  PreferOrPattern.id: PreferOrPattern,
224
226
  RequirePortForService.id: RequirePortForService,
@@ -44,6 +44,7 @@ _MIN_STATEMENTS = 3
44
44
 
45
45
  # Members of a normalized-body group before it counts as copy-paste.
46
46
  _MIN_GROUP = 2
47
+ _MIN_SHORT_CASE_GROUP = 3
47
48
 
48
49
  # Small source-checker pairs often read better as individually named contracts.
49
50
  # A longer uninterrupted run is a case table whose repeated shell obscures the cases.
@@ -114,21 +115,22 @@ class NoRepeatedTestBody(Rule):
114
115
  limitations=(
115
116
  "Only substantial sibling test bodies in one non-generated module are compared.",
116
117
  "A run of at least five two-statement embedded-source checker cases is compared because the source documents are natural parameter values.",
118
+ "At least three consecutive call-and-literal-equality cases with corroborating behavior names are compared even with only two statements.",
117
119
  "Meaningful docstring or comment differences keep tests distinct.",
118
120
  "Two-test groups with varying literals require corroborating behavior names; long scenario prose and distinct API resources remain separate contracts.",
119
121
  ),
120
122
  examples=(
121
123
  RuleExample(
122
- example_id="copy-pasted-tests",
123
- title="Copy-pasted tests hide the changing case dimension",
124
+ example_id="short-literal-cases",
125
+ title="Consecutive short cases repeat the same call and assertion",
124
126
  outcome=ExampleOutcome.MATCH,
125
127
  files=(
126
128
  ExampleFile.python(
127
- "tests/test_permissions.py",
128
- 'def test_rejects_blank_role():\n role = parse_role("")\n result = validate(role)\n assert result.invalid\n\ndef test_rejects_space_role():\n role = parse_role(" ")\n result = validate(role)\n assert result.invalid\n\ndef test_rejects_tab_role():\n role = parse_role("\\t")\n result = validate(role)\n assert result.invalid\n',
129
+ "tests/test_parser.py",
130
+ 'def test_parse_one():\n result = parse("a")\n assert result == 1\n\ndef test_parse_two():\n result = parse("b")\n assert result == 2\n\ndef test_parse_three():\n result = parse("c")\n assert result == 3\n',
129
131
  ),
130
132
  ),
131
- focus_path=PurePosixPath("tests/test_permissions.py"),
133
+ focus_path=PurePosixPath("tests/test_parser.py"),
132
134
  expected_count=1,
133
135
  public=True,
134
136
  ),
@@ -349,7 +351,7 @@ def _duplicate_groups(tree: ast.Module, source: str) -> list[list[_Shape]]:
349
351
  if in_test_case or _uses_unittest_api(node):
350
352
  continue
351
353
  outline = _Outline(node, container)
352
- if outline.statements < _MIN_STATEMENTS and not outline.embedded_source_checker:
354
+ if outline.statements < _MIN_STATEMENTS and not (outline.embedded_source_checker or _is_short_case(node)):
353
355
  continue
354
356
  outlines.setdefault(outline.key, []).append(outline)
355
357
 
@@ -366,11 +368,22 @@ def _duplicate_groups(tree: ast.Module, source: str) -> list[list[_Shape]]:
366
368
  shape = _Shape(outline.node, comments, imports)
367
369
  groups.setdefault(shape.key, []).append(shape)
368
370
  for members in groups.values():
369
- candidates = (
370
- _consecutive_embedded_source_groups(members, positions)
371
- if all(member.embedded_source_checker for member in members)
372
- else [members]
373
- )
371
+ if all(_is_short_case(member.node) for member in members):
372
+ candidates = [
373
+ run
374
+ for run in _consecutive_groups(members, positions)
375
+ if len(run) >= _MIN_SHORT_CASE_GROUP
376
+ and _test_name_tokens(run[0].node.name).intersection(
377
+ *(_test_name_tokens(member.node.name) for member in run[1:])
378
+ )
379
+ - _WEAK_TEST_NAME_TOKENS
380
+ ]
381
+ else:
382
+ candidates = (
383
+ _consecutive_embedded_source_groups(members, positions)
384
+ if all(member.embedded_source_checker for member in members)
385
+ else [members]
386
+ )
374
387
  found.extend(
375
388
  candidate
376
389
  for candidate in candidates
@@ -391,13 +404,21 @@ def duplicate_test_owner_ids(tree: ast.Module, source: str) -> frozenset[int]:
391
404
 
392
405
 
393
406
  def _consecutive_embedded_source_groups(members: list[_Shape], positions: dict[int, int]) -> list[list[_Shape]]:
407
+ return [
408
+ run
409
+ for run in _consecutive_groups(members, positions)
410
+ if len(run) >= _MIN_EMBEDDED_SOURCE_GROUP and _shares_embedded_source_signal(run)
411
+ ]
412
+
413
+
414
+ def _consecutive_groups(members: list[_Shape], positions: dict[int, int]) -> list[list[_Shape]]:
394
415
  ordered = sorted(members, key=lambda member: positions[id(member.node)])
395
416
  runs: list[list[_Shape]] = []
396
417
  for member in ordered:
397
418
  if not runs or positions[id(member.node)] != positions[id(runs[-1][-1].node)] + 1:
398
419
  runs.append([])
399
420
  runs[-1].append(member)
400
- return [run for run in runs if len(run) >= _MIN_EMBEDDED_SOURCE_GROUP and _shares_embedded_source_signal(run)]
421
+ return runs
401
422
 
402
423
 
403
424
  def _shares_embedded_source_signal(members: list[_Shape]) -> bool:
@@ -546,6 +567,19 @@ def _body_without_docstring(node: ast.FunctionDef | ast.AsyncFunctionDef) -> lis
546
567
  return body
547
568
 
548
569
 
570
+ def _is_short_case(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
571
+ match _body_without_docstring(node):
572
+ case [
573
+ ast.Assign(targets=[ast.Name(id=name)], value=ast.Call()),
574
+ ast.Assert(
575
+ test=ast.Compare(left=ast.Name(id=result), ops=[ast.Eq()], comparators=[ast.Constant()]), msg=None
576
+ ),
577
+ ]:
578
+ return name == result
579
+ case _:
580
+ return False
581
+
582
+
549
583
  def _is_embedded_source_checker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
550
584
  match _body_without_docstring(node):
551
585
  case [
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, TypeGuard, final
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ is_suppressed,
18
+ parse_or_none,
19
+ )
20
+ from sarj_python_lint.rules._ast_index import nodes
21
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from pathlib import Path
26
+
27
+
28
+ _MIN_INVARIANT_CALLS = 2
29
+
30
+ _REFLECTION = frozenset({"globals", "locals", "vars", "eval", "exec", "getattr", "__import__", "__all__"})
31
+
32
+
33
+ @final
34
+ class UnusedTestFactoryOption(Rule):
35
+ id = "unused-test-factory-option"
36
+ code = "SARJ443"
37
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
38
+ summary="A private test factory exposes a literal option that visible callers never vary.",
39
+ rationale="Unused customization obscures the values that actually distinguish test scenarios.",
40
+ remediation="Keep the value in the factory's construction instead of exposing an unused option; retain it if external callers need it.",
41
+ category=RuleCategory.TESTING,
42
+ autofix=AutofixPolicy.NONE,
43
+ limitations=(
44
+ "Only module-level private _make_ and _build_ helpers consisting of a single return call are considered.",
45
+ "Only literal defaults with direct, unambiguous calls in this file are reported. Explicit options require at least two calls with the same effective literal value. Decorators, rebinding, callable escapes, reflection, and argument unpacking exclude the helper.",
46
+ "Cross-module callers cannot be proven absent. Shared helpers require an exact suppression; this advisory never autofixes signatures or changes default evaluation timing.",
47
+ ),
48
+ examples=(
49
+ RuleExample(
50
+ example_id="invariant-size-option",
51
+ title="Repeated calls never vary the option",
52
+ outcome=ExampleOutcome.MATCH,
53
+ files=(
54
+ ExampleFile.python(
55
+ "tests/test_widget.py",
56
+ "def _make_widget(*, size=3):\n return Widget(size=size)\n\n_make_widget()\n_make_widget(size=3)\n",
57
+ ),
58
+ ),
59
+ focus_path=PurePosixPath("tests/test_widget.py"),
60
+ expected_count=1,
61
+ public=True,
62
+ ),
63
+ RuleExample(
64
+ example_id="exercised-size-option",
65
+ title="Keep customization that a caller exercises",
66
+ outcome=ExampleOutcome.NO_MATCH,
67
+ files=(
68
+ ExampleFile.python(
69
+ "tests/test_widget.py",
70
+ "def _make_widget(*, size=3):\n return Widget(size=size)\n\ndef test_widget():\n assert _make_widget(size=4)\n",
71
+ ),
72
+ ),
73
+ focus_path=PurePosixPath("tests/test_widget.py"),
74
+ expected_count=0,
75
+ public=True,
76
+ ),
77
+ ),
78
+ )
79
+ description = documentation.summary
80
+
81
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
82
+ if not is_test_path(path) or is_generated(path, source) or not ("_make_" in source or "_build_" in source):
83
+ return []
84
+ tree = parse_or_none(path, source)
85
+ if tree is None or (
86
+ any(node.id in _REFLECTION for node in nodes(tree, ast.Name))
87
+ or any(node.attr in _REFLECTION or node.attr == "__dict__" for node in nodes(tree, ast.Attribute))
88
+ or any(node.name in _REFLECTION or node.name == "*" for node in nodes(tree, ast.alias))
89
+ ):
90
+ return []
91
+ lines = source.splitlines()
92
+ findings: list[Diagnostic] = []
93
+ for function in tree.body:
94
+ if not _is_factory(function):
95
+ continue
96
+ calls = _direct_calls(tree, function)
97
+ if not calls:
98
+ continue
99
+ bound = _bound_calls(function, calls)
100
+ if bound is None:
101
+ continue
102
+ supplied = {name for arguments in bound for name in arguments}
103
+ positional = [*function.args.posonlyargs, *function.args.args]
104
+ defaults = [
105
+ *zip(positional[len(positional) - len(function.args.defaults) :], function.args.defaults, strict=True),
106
+ *zip(function.args.kwonlyargs, function.args.kw_defaults, strict=True),
107
+ ]
108
+ used = {node.id for node in nodes(function, ast.Name) if isinstance(node.ctx, ast.Load)}
109
+ for argument, default in defaults:
110
+ if argument.arg not in used or not isinstance(default, ast.Constant):
111
+ continue
112
+ if default.value is not None and not isinstance(default.value, (str, bytes, int, float)):
113
+ continue
114
+ explicitly_supplied = argument.arg in supplied
115
+ if explicitly_supplied:
116
+ values = [arguments.get(argument.arg, default) for arguments in bound]
117
+ if len(values) < _MIN_INVARIANT_CALLS or any(
118
+ not isinstance(value, ast.Constant) or ast.dump(value) != ast.dump(values[0])
119
+ for value in values
120
+ ):
121
+ continue
122
+ if is_suppressed(lines, argument.lineno, self.code):
123
+ continue
124
+ findings.append(
125
+ Diagnostic(
126
+ path=path,
127
+ line=argument.lineno,
128
+ col=argument.col_offset + 1,
129
+ code=self.code,
130
+ severity=Severity.WARNING,
131
+ message=(
132
+ f"Direct callers in this file always use the same literal for `{function.name}.{argument.arg}`; "
133
+ "consider keeping that value in the factory instead of repeating an invariant option. Retain it if external callers need it."
134
+ if explicitly_supplied
135
+ else f"No direct caller in this file supplies `{function.name}.{argument.arg}`; keep its literal value in the factory instead of exposing an unused option. Retain it if external callers need it."
136
+ ),
137
+ )
138
+ )
139
+ return sorted(findings, key=lambda item: (item.line, item.col))
140
+
141
+
142
+ def _is_factory(node: ast.stmt) -> TypeGuard[ast.FunctionDef]:
143
+ return (
144
+ isinstance(node, ast.FunctionDef)
145
+ and node.name.startswith(("_make_", "_build_"))
146
+ and not node.decorator_list
147
+ and node.args.vararg is None
148
+ and node.args.kwarg is None
149
+ and len(node.body) == 1
150
+ and isinstance(node.body[0], ast.Return)
151
+ and isinstance(node.body[0].value, ast.Call)
152
+ )
153
+
154
+
155
+ def _direct_calls(tree: ast.Module, function: ast.FunctionDef) -> list[ast.Call]:
156
+ name = function.name
157
+ if sum(node.name == name for node in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) != 1:
158
+ return []
159
+ if any(node.arg == name for node in nodes(tree, ast.arg)):
160
+ return []
161
+ if any((node.asname or node.name.split(".")[0]) == name for node in nodes(tree, ast.alias)):
162
+ return []
163
+ if any(node.attr == name for node in nodes(tree, ast.Attribute)):
164
+ return []
165
+ if any(node.value == name for node in nodes(tree, ast.Constant)):
166
+ return []
167
+ if any(node.name == name for node in nodes(tree, ast.MatchAs, ast.MatchStar, ast.ExceptHandler)):
168
+ return []
169
+ if any(node.rest == name for node in nodes(tree, ast.MatchMapping)):
170
+ return []
171
+ references = [node for node in nodes(tree, ast.Name) if node.id == name]
172
+ calls = [node for node in nodes(tree, ast.Call) if isinstance(node.func, ast.Name) and node.func.id == name]
173
+ if not calls or len(references) != len(calls) or any(not isinstance(node.ctx, ast.Load) for node in references):
174
+ return []
175
+ if any(
176
+ any(isinstance(arg, ast.Starred) for arg in call.args) or any(kw.arg is None for kw in call.keywords)
177
+ for call in calls
178
+ ):
179
+ return []
180
+ if any(function.lineno <= call.lineno <= (function.end_lineno or function.lineno) for call in calls):
181
+ return []
182
+ return calls
183
+
184
+
185
+ def _bound_calls(function: ast.FunctionDef, calls: list[ast.Call]) -> list[dict[str, ast.expr]] | None:
186
+ positional = [*function.args.posonlyargs, *function.args.args]
187
+ keywords = {arg.arg for arg in (*function.args.args, *function.args.kwonlyargs)}
188
+ required = {arg.arg for arg in positional[: len(positional) - len(function.args.defaults)]}
189
+ required.update(
190
+ arg.arg
191
+ for arg, default in zip(function.args.kwonlyargs, function.args.kw_defaults, strict=True)
192
+ if default is None
193
+ )
194
+ bound: list[dict[str, ast.expr]] = []
195
+ for call in calls:
196
+ if len(call.args) > len(positional):
197
+ return None
198
+ arguments = {arg.arg: value for arg, value in zip(positional[: len(call.args)], call.args, strict=True)}
199
+ for keyword in call.keywords:
200
+ if keyword.arg not in keywords or keyword.arg in arguments:
201
+ return None
202
+ arguments[keyword.arg] = keyword.value
203
+ if not required <= arguments.keys():
204
+ return None
205
+ bound.append(arguments)
206
+ return bound