sarj-python-lint 1.10.1__tar.gz → 1.13.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 (169) hide show
  1. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  4. sarj_python_lint-1.13.0/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +205 -0
  5. sarj_python_lint-1.13.0/src/sarj_python_lint/rules/require_precise_factory_signature.py +252 -0
  6. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +94 -11
  7. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/.gitignore +0 -0
  8. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/LICENSE +0 -0
  9. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/README.md +0 -0
  10. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  13. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_file_context.py +0 -0
  14. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_filesystem.py +0 -0
  15. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_python_target.py +0 -0
  16. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  17. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/benchmark.py +0 -0
  20. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/json_boundary.py +0 -0
  21. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rule_base.py +0 -0
  24. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  25. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  26. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  28. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  29. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  30. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  31. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  32. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  33. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  34. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  35. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  36. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  37. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  38. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  39. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  40. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  41. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  42. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  43. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  44. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  45. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  46. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  47. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  48. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  49. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  50. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  51. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  52. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -0
  53. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  54. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  55. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  56. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  57. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  58. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  59. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  60. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  61. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  62. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  63. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  64. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  65. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  66. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  67. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  68. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  69. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  70. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  71. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  72. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  73. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  74. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  75. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  76. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  77. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  78. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  79. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  80. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  81. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  82. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  83. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  84. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  85. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  86. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  87. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  88. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  89. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  90. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  91. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  92. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  93. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  94. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  95. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  96. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  97. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  98. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  99. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  100. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  101. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  102. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  103. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  104. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  105. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  106. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  107. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  108. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  109. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  110. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  111. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  112. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  113. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  114. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  115. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  116. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  117. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  118. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  119. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  120. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  121. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  122. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  123. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  124. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  125. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  126. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  127. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  128. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  129. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  130. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  131. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  132. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  133. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  134. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  135. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  136. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  137. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  138. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  139. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  140. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  141. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  142. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  143. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  144. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  145. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  146. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  147. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  148. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  149. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  150. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  151. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  152. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  153. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  154. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  155. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  156. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  157. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  158. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  159. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  160. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  161. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  162. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  163. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  164. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  165. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  166. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  167. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  168. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  169. {sarj_python_lint-1.10.1 → sarj_python_lint-1.13.0}/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: 1.10.1
3
+ Version: 1.13.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 = "1.10.1"
3
+ version = "1.13.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" }]
@@ -79,6 +79,7 @@ from sarj_python_lint.rules.no_restated_comment import NoRestatedComment
79
79
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
80
80
  from sarj_python_lint.rules.no_select_star import NoSelectStar
81
81
  from sarj_python_lint.rules.no_service_behavior_in_settings import NoServiceBehaviorInSettings
82
+ from sarj_python_lint.rules.no_shallow_container_type_guard import NoShallowContainerTypeGuard
82
83
  from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
83
84
  from sarj_python_lint.rules.no_swallowed_asyncio_cancellation import NoSwallowedAsyncioCancellation
84
85
  from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
@@ -164,6 +165,7 @@ from sarj_python_lint.rules.require_nodecode_for_splitting_settings_field import
164
165
  RequireNoDecodeForSplittingSettingsField,
165
166
  )
166
167
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
168
+ from sarj_python_lint.rules.require_precise_factory_signature import RequirePreciseFactorySignature
167
169
  from sarj_python_lint.rules.require_public_dependency_contract import RequirePublicDependencyContract
168
170
  from sarj_python_lint.rules.require_pydantic_for_external_json import (
169
171
  RequirePydanticForExternalJson,
@@ -199,6 +201,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
199
201
  RequireExplicitContractImplementation.id: RequireExplicitContractImplementation,
200
202
  DiscourageNullableConstructorParameters.id: DiscourageNullableConstructorParameters,
201
203
  RequirePublicDependencyContract.id: RequirePublicDependencyContract,
204
+ RequirePreciseFactorySignature.id: RequirePreciseFactorySignature,
202
205
  RequireExplicitServiceContract.id: RequireExplicitServiceContract,
203
206
  AsyncCleanupRegisteredSynchronously.id: AsyncCleanupRegisteredSynchronously,
204
207
  SubprocessKillWithoutReap.id: SubprocessKillWithoutReap,
@@ -260,6 +263,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
260
263
  NoHiddenConstructorFallback.id: NoHiddenConstructorFallback,
261
264
  NoInjectedModuleLoader.id: NoInjectedModuleLoader,
262
265
  NoNullableDependencyFallback.id: NoNullableDependencyFallback,
266
+ NoShallowContainerTypeGuard.id: NoShallowContainerTypeGuard,
263
267
  NoFileLevelEscapeHatchSuppression.id: NoFileLevelEscapeHatchSuppression,
264
268
  NoInvalidArgumentNameSuppression.id: NoInvalidArgumentNameSuppression,
265
269
  NoFastapiOnEvent.id: NoFastapiOnEvent,
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
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
+ )
19
+ from sarj_python_lint.rules._imports import TYPING_SOURCES
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from sarj_python_lint._file_context import PythonFileContext
24
+ from sarj_python_lint.rules._imports import ImportIndex
25
+
26
+
27
+ _CONTAINERS = frozenset({"dict", "list", "set"})
28
+ _MAPPING_SLOTS = 2
29
+ _SLOTS = frozenset({"object", "str", "int", "bool", "float", "bytes"})
30
+ _BUILTINS = _SLOTS | _CONTAINERS | {"isinstance"}
31
+ _MUTATION_TARGETS = {f"builtins.{symbol}" for symbol in _BUILTINS} | {
32
+ "typing.TypeGuard",
33
+ "typing.TypeIs",
34
+ "typing_extensions.TypeGuard",
35
+ "typing_extensions.TypeIs",
36
+ }
37
+ _BAD = "from typing import TypeGuard\ndef is_mapping(value: object) -> TypeGuard[dict[str, object]]:\n return isinstance(value, dict)\n"
38
+ _GOOD = _BAD.replace("dict[str, object]", "dict[object, object]")
39
+
40
+
41
+ @final
42
+ class NoShallowContainerTypeGuard(Rule):
43
+ id = "no-shallow-container-type-guard"
44
+ code = "SARJ472"
45
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
46
+ default_level=Severity.WARNING,
47
+ summary="Do not claim typed container contents from a container-only type guard.",
48
+ rationale="TypeGuard and TypeIs let callers trust their declared narrowing. Checking only dict, list, or set does not validate keys, values, or elements, so callers can perform operations that fail at runtime.",
49
+ remediation="Use object for unchecked container slots, or validate their contents when the narrower contract is required. Keep constrained input types that already prove the contents.",
50
+ category=RuleCategory.CORRECTNESS,
51
+ autofix=AutofixPolicy.NONE,
52
+ limitations=(
53
+ "Checks top-level, undecorated, non-generic synchronous functions with one positional parameter proven to be builtin object and a single return isinstance(parameter, builtin_container), optionally after a docstring.",
54
+ "The TypeGuard or TypeIs target must be a matching builtin dict, list, or set whose slots all resolve to object, str, int, bool, float, or bytes; at least one slot must be narrower than object.",
55
+ "Includes authored tests; generated and vendor source is excluded. String annotations, constrained input types, unions, Any, TypeVars, Protocols, assignment aliases, nested definitions, and content validators are not inferred.",
56
+ "Annotation imports include TYPE_CHECKING imports. Runtime imports use conservative file-wide shadow facts, so unrelated local rebinding can cause false negatives. Wildcard imports, explicit writes to relevant builtin or typing attributes, and exception, pattern or walrus bindings that shadow relevant names or imported aliases exclude the file; dynamic namespace mutation is not inferred.",
57
+ "No autofix: widening the return contract can require caller changes, while adding content validation changes behavior and cost.",
58
+ ),
59
+ examples=(
60
+ RuleExample(
61
+ example_id="unchecked-dictionary-keys",
62
+ title="A container check does not prove string keys",
63
+ outcome=ExampleOutcome.MATCH,
64
+ files=(ExampleFile.python("app/values.py", _BAD),),
65
+ focus_path=PurePosixPath("app/values.py"),
66
+ expected_count=1,
67
+ public=True,
68
+ ),
69
+ RuleExample(
70
+ example_id="honest-dictionary-keys",
71
+ title="Unchecked keys retain their object type",
72
+ outcome=ExampleOutcome.NO_MATCH,
73
+ files=(ExampleFile.python("app/values.py", _GOOD),),
74
+ focus_path=PurePosixPath("app/values.py"),
75
+ expected_count=0,
76
+ public=True,
77
+ ),
78
+ ),
79
+ )
80
+ description = documentation.summary
81
+
82
+ @override
83
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
84
+ if context.tree is None or context.generated or _ambiguous_symbols(context):
85
+ return []
86
+ findings: list[Diagnostic] = []
87
+ for function in context.tree.body:
88
+ if not isinstance(function, ast.FunctionDef):
89
+ continue
90
+ statement = _shallow_guard(function, context)
91
+ if statement is None or is_suppressed(context.source_lines, statement.lineno, self.code):
92
+ continue
93
+ findings.append(
94
+ Diagnostic(
95
+ path=context.path,
96
+ line=statement.lineno,
97
+ col=statement.col_offset + 1,
98
+ code=self.code,
99
+ message="Container-only isinstance check does not validate the declared container contents; use object for unchecked slots or validate the contents required by the return type.",
100
+ severity=Severity.WARNING,
101
+ )
102
+ )
103
+ return findings
104
+
105
+
106
+ def _shallow_guard(function: ast.FunctionDef, context: PythonFileContext) -> ast.Return | None:
107
+ arguments = function.args
108
+ positional = (*arguments.posonlyargs, *arguments.args)
109
+ if function.decorator_list or function.type_params or len(positional) != 1:
110
+ return None
111
+ if arguments.kwonlyargs or arguments.vararg is not None or arguments.kwarg is not None:
112
+ return None
113
+ parameter = positional[0]
114
+ if _builtin_symbol(parameter.annotation, context.module_imports) != "object":
115
+ return None
116
+ name = _guarded_container(function.returns, context.module_imports)
117
+ if name is None:
118
+ return None
119
+ body = function.body
120
+ if (
121
+ body
122
+ and isinstance(body[0], ast.Expr)
123
+ and isinstance(body[0].value, ast.Constant)
124
+ and isinstance(body[0].value.value, str)
125
+ ):
126
+ body = body[1:]
127
+ if len(body) != 1 or not isinstance(body[0], ast.Return):
128
+ return None
129
+ statement = body[0]
130
+ call = statement.value
131
+ if not isinstance(call, ast.Call) or call.keywords:
132
+ return None
133
+ match call.args:
134
+ case [ast.Name(id=guarded_name), checked_container] if (
135
+ guarded_name == parameter.arg
136
+ and _builtin_symbol(call.func, context.imports) == "isinstance"
137
+ and _builtin_symbol(checked_container, context.imports) == name
138
+ ):
139
+ return statement
140
+ case _:
141
+ return None
142
+
143
+
144
+ def _guarded_container(annotation: ast.expr | None, imports: ImportIndex) -> str | None:
145
+ if not isinstance(annotation, ast.Subscript) or imports.resolved_symbol(
146
+ annotation.value, sources=TYPING_SOURCES
147
+ ) not in {"TypeGuard", "TypeIs"}:
148
+ return None
149
+ container = annotation.slice
150
+ if not isinstance(container, ast.Subscript):
151
+ return None
152
+ name = _builtin_symbol(container.value, imports)
153
+ if name not in _CONTAINERS:
154
+ return None
155
+ slots = container.slice.elts if isinstance(container.slice, ast.Tuple) else [container.slice]
156
+ kinds = [_builtin_symbol(slot, imports) for slot in slots]
157
+ if (
158
+ len(kinds) != (_MAPPING_SLOTS if name == "dict" else 1)
159
+ or any(kind not in _SLOTS for kind in kinds)
160
+ or all(kind == "object" for kind in kinds)
161
+ ):
162
+ return None
163
+ return name
164
+
165
+
166
+ def _builtin_symbol(node: ast.expr | None, imports: ImportIndex) -> str | None:
167
+ if node is None:
168
+ return None
169
+ symbol = imports.resolved_symbol(node, sources=frozenset({"builtins"}))
170
+ if symbol in _BUILTINS:
171
+ return symbol
172
+ if isinstance(node, ast.Name) and node.id in _BUILTINS and imports.builtin_is_unshadowed(node.id):
173
+ return node.id
174
+ return None
175
+
176
+
177
+ def _ambiguous_symbols(context: PythonFileContext) -> bool:
178
+ sensitive_names = _BUILTINS | {
179
+ name
180
+ for name, target in context.module_imports.bindings.items()
181
+ if target.module in TYPING_SOURCES | {"builtins"}
182
+ and (target.symbol is None or target.symbol in _BUILTINS | {"TypeGuard", "TypeIs"})
183
+ }
184
+ for node in context.nodes(
185
+ ast.ImportFrom, ast.Attribute, ast.ExceptHandler, ast.MatchAs, ast.MatchStar, ast.MatchMapping, ast.NamedExpr
186
+ ):
187
+ match node:
188
+ case ast.ImportFrom(names=aliases):
189
+ if any(alias.name == "*" for alias in aliases):
190
+ return True
191
+ case ast.Attribute(ctx=(ast.Store() | ast.Del())):
192
+ if context.module_imports.resolved_qualified_name(node) in _MUTATION_TARGETS:
193
+ return True
194
+ case (
195
+ ast.ExceptHandler(name=name)
196
+ | ast.MatchAs(name=name)
197
+ | ast.MatchStar(name=name)
198
+ | ast.MatchMapping(rest=name)
199
+ | ast.NamedExpr(target=ast.Name(id=name))
200
+ ):
201
+ if name in sensitive_names:
202
+ return True
203
+ case _:
204
+ pass
205
+ return False
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ ProjectRule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ )
18
+ from sarj_python_lint.rules._ast_index import walk
19
+ from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from sarj_python_lint._file_context import PythonFileContext
24
+
25
+
26
+ _CALLABLE_ARITY = 2
27
+
28
+ _CALLABLES = frozenset({"typing.Callable", "collections.abc.Callable", "typing_extensions.Callable"})
29
+
30
+
31
+ @final
32
+ class RequirePreciseFactorySignature(ProjectRule):
33
+ id = "require-precise-factory-signature"
34
+ code = "SARJ473"
35
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
36
+ default_level=Severity.WARNING,
37
+ summary="Keep fixed keyword factory calls behind a precise callable signature.",
38
+ rationale="Callable[..., T] erases argument checking, so misspelled keywords and incompatible injected factories pass strict typing and can fail at runtime.",
39
+ remediation="Use an exact callable Protocol for substitutable factories. Use type[T] when this dependency genuinely requires a class, accounting for subclass constructor compatibility.",
40
+ category=RuleCategory.ARCHITECTURE,
41
+ autofix=AutofixPolicy.NONE,
42
+ limitations=(
43
+ "Only undecorated module-level functions with proven standard Callable[..., OwnedClass] parameters directly called with at least one named keyword are considered. Imported classes require scanned first-party declarations.",
44
+ "Tests, test support, generated code, unresolved/string/generic annotations, positional-only calls, unpacking, forwarding, nested captures, rebinding and ambiguous imports are excluded. Import capture checks are conservative across the file, so an unrelated local pattern, exception or walrus binding can prevent a warning.",
45
+ "A fixed call demonstrates signature erasure, not a current runtime defect. Genuine dynamic APIs can use a reasoned suppression. No autofix: the correct contract depends on whether classes or function factories are intended.",
46
+ ),
47
+ examples=(
48
+ RuleExample(
49
+ example_id="erased-factory",
50
+ title="Keyword arguments escape checking",
51
+ outcome=ExampleOutcome.MATCH,
52
+ files=(
53
+ ExampleFile.python(
54
+ "app/service.py",
55
+ "from collections.abc import Callable\nclass Result: pass\ndef build(factory: Callable[..., Result]):\n return factory(size=4)\n",
56
+ ),
57
+ ),
58
+ focus_path=PurePosixPath("app/service.py"),
59
+ expected_count=1,
60
+ public=True,
61
+ ),
62
+ RuleExample(
63
+ example_id="exact-factory",
64
+ title="Factory declares its keyword contract",
65
+ outcome=ExampleOutcome.NO_MATCH,
66
+ files=(
67
+ ExampleFile.python(
68
+ "app/service.py",
69
+ "from typing import Protocol\nclass Result: pass\nclass Factory(Protocol):\n def __call__(self, *, size: int) -> Result: ...\ndef build(factory: Factory):\n return factory(size=4)\n",
70
+ ),
71
+ ),
72
+ focus_path=PurePosixPath("app/service.py"),
73
+ expected_count=0,
74
+ public=True,
75
+ ),
76
+ ),
77
+ )
78
+ description = documentation.summary
79
+
80
+ @override
81
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
82
+ tree = context.tree
83
+ if tree is None or context.generated or is_test_path(context.path) or is_test_support_path(context.path):
84
+ return []
85
+ if any(alias.name == "*" for node in context.nodes(ast.ImportFrom) for alias in node.names):
86
+ return []
87
+ findings: list[Diagnostic] = []
88
+ for function in tree.body:
89
+ if (
90
+ not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef))
91
+ or function.decorator_list
92
+ or function.type_params
93
+ ):
94
+ continue
95
+ for argument in (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs):
96
+ result = _factory_result(argument.annotation, context)
97
+ if (
98
+ result is None
99
+ or not _fixed_calls(function, argument.arg, context)
100
+ or not _owned_result(result, function, context)
101
+ ):
102
+ continue
103
+ findings.append(
104
+ Diagnostic(
105
+ path=context.path,
106
+ line=argument.lineno,
107
+ col=argument.col_offset + 1,
108
+ code=self.code,
109
+ message=f"`{argument.arg}` is called with fixed keywords but Callable[..., T] erases its argument contract; use a precise callable signature.",
110
+ severity=Severity.WARNING,
111
+ )
112
+ )
113
+ return sorted(findings, key=lambda finding: (finding.line, finding.col))
114
+
115
+
116
+ def _factory_result(annotation: ast.expr | None, context: PythonFileContext) -> ast.Name | ast.Attribute | None:
117
+ if (
118
+ not isinstance(annotation, ast.Subscript)
119
+ or context.module_imports.resolved_qualified_name(annotation.value) not in _CALLABLES
120
+ ):
121
+ return None
122
+ if not isinstance(annotation.slice, ast.Tuple) or len(annotation.slice.elts) != _CALLABLE_ARITY:
123
+ return None
124
+ parameters, result = annotation.slice.elts
125
+ if (
126
+ not isinstance(parameters, ast.Constant)
127
+ or parameters.value is not Ellipsis
128
+ or not isinstance(result, (ast.Name, ast.Attribute))
129
+ ):
130
+ return None
131
+ root = _root(annotation.value)
132
+ if (root is not None and _captured_import_root(root, context)) or any(
133
+ isinstance(node.ctx, (ast.Store, ast.Del)) and _root(node) == root for node in context.nodes(ast.Attribute)
134
+ ):
135
+ return None
136
+ return result
137
+
138
+
139
+ def _fixed_calls(function: ast.FunctionDef | ast.AsyncFunctionDef, name: str, context: PythonFileContext) -> bool:
140
+ contents = [node for statement in function.body for node in walk(statement)]
141
+ references = [node for node in contents if isinstance(node, ast.Name) and node.id == name]
142
+ return (
143
+ bool(references)
144
+ and not any(_binds_name(node, name) for node in contents)
145
+ and all(_direct_keyword_call(reference, function, context) for reference in references)
146
+ )
147
+
148
+
149
+ def _direct_keyword_call(
150
+ reference: ast.Name, function: ast.FunctionDef | ast.AsyncFunctionDef, context: PythonFileContext
151
+ ) -> bool:
152
+ call = context.parents.get(reference)
153
+ if not isinstance(call, ast.Call) or call.func is not reference or not call.keywords:
154
+ return False
155
+ if any(keyword.arg is None for keyword in call.keywords) or any(isinstance(arg, ast.Starred) for arg in call.args):
156
+ return False
157
+ parent = context.parents.get(call)
158
+ while parent is not None and parent is not function:
159
+ if isinstance(
160
+ parent,
161
+ (
162
+ ast.FunctionDef,
163
+ ast.AsyncFunctionDef,
164
+ ast.Lambda,
165
+ ast.ClassDef,
166
+ ast.ListComp,
167
+ ast.SetComp,
168
+ ast.DictComp,
169
+ ast.GeneratorExp,
170
+ ),
171
+ ):
172
+ return False
173
+ parent = context.parents.get(parent)
174
+ return parent is function
175
+
176
+
177
+ def _owned_result(
178
+ result: ast.Name | ast.Attribute, function: ast.FunctionDef | ast.AsyncFunctionDef, context: PythonFileContext
179
+ ) -> bool:
180
+ tree = context.tree
181
+ if tree is None:
182
+ return False
183
+ root = _root(result)
184
+ if any(isinstance(node.ctx, (ast.Store, ast.Del)) and _root(node) == root for node in context.nodes(ast.Attribute)):
185
+ return False
186
+ local = next(
187
+ (
188
+ node
189
+ for node in tree.body
190
+ if isinstance(node, ast.ClassDef) and isinstance(result, ast.Name) and node.name == result.id
191
+ ),
192
+ None,
193
+ )
194
+ if local is not None:
195
+ return local.lineno < function.lineno and _stable_class(tree, local.name)
196
+ if root not in context.module_imports.bindings or (root is not None and _captured_import_root(root, context)):
197
+ return False
198
+ project = context.session.project
199
+ if project is None or (unit := project.unit_or_source(context.path, context.source, tree)) is None:
200
+ return False
201
+ summary = project.class_for(unit, result)
202
+ if (
203
+ summary is None
204
+ or (declaration := project.source_unit(summary.symbol.module)) is None
205
+ or declaration.tree is None
206
+ ):
207
+ return False
208
+ return not (
209
+ is_generated(declaration.path, declaration.source)
210
+ or is_test_path(declaration.path)
211
+ or is_test_support_path(declaration.path)
212
+ ) and _stable_class(declaration.tree, summary.symbol.name)
213
+
214
+
215
+ def _stable_class(tree: ast.Module, name: str) -> bool:
216
+ declarations = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == name]
217
+ if len(declarations) != 1 or declarations[0].decorator_list:
218
+ return False
219
+ return not any(_binds_name(node, name) for node in walk(tree) if node is not declarations[0])
220
+
221
+
222
+ def _captured_import_root(name: str, context: PythonFileContext) -> bool:
223
+ return any(
224
+ _binds_name(node, name)
225
+ for node in context.nodes(ast.MatchAs, ast.MatchStar, ast.MatchMapping, ast.ExceptHandler)
226
+ ) or any(node.target.id == name for node in context.nodes(ast.NamedExpr))
227
+
228
+
229
+ def _binds_name(node: ast.AST, name: str) -> bool:
230
+ match node:
231
+ case (
232
+ ast.Name(id=bound, ctx=(ast.Store() | ast.Del()))
233
+ | ast.arg(arg=bound)
234
+ | ast.FunctionDef(name=bound)
235
+ | ast.AsyncFunctionDef(name=bound)
236
+ | ast.ClassDef(name=bound)
237
+ | ast.ExceptHandler(name=str() as bound)
238
+ | ast.MatchAs(name=str() as bound)
239
+ | ast.MatchStar(name=str() as bound)
240
+ | ast.MatchMapping(rest=str() as bound)
241
+ ):
242
+ return bound == name
243
+ case ast.alias(name=imported, asname=alias):
244
+ return (alias or imported.split(".")[0]) == name
245
+ case _:
246
+ return False
247
+
248
+
249
+ def _root(node: ast.expr) -> str | None:
250
+ while isinstance(node, ast.Attribute):
251
+ node = node.value
252
+ return node.id if isinstance(node, ast.Name) else None
@@ -38,14 +38,15 @@ class UnusedTestFactoryOption(Rule):
38
38
  code = "SARJ443"
39
39
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
40
40
  default_level=Severity.WARNING,
41
- summary="A private test factory exposes a literal option that visible callers never vary.",
41
+ summary="A private test factory exposes an option that visible callers never vary.",
42
42
  rationale="Unused customization obscures the values that actually distinguish test scenarios.",
43
43
  remediation="Keep the value in the factory's construction instead of exposing an unused option; retain it if external callers need it.",
44
44
  category=RuleCategory.TESTING,
45
45
  autofix=AutofixPolicy.NONE,
46
46
  limitations=(
47
47
  "Only module-level private _make_ and _build_ helpers with straight-line local assignments ending in a construction return are considered; control flow and standalone side effects are excluded.",
48
- "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.",
48
+ "Literal defaults and stable earlier module-local function defaults are considered. Callable options are reported only when every direct caller omits them; explicit literal options require at least two calls with the same effective value.",
49
+ "Decorators, rebinding, shadowing, callable escapes, reflection, argument unpacking, and ambiguous provider mutation or patch targets exclude the affected helper or callable option.",
49
50
  "Cross-module callers cannot be proven absent. Shared helpers require an exact suppression; this advisory never autofixes signatures or changes default evaluation timing.",
50
51
  ),
51
52
  examples=(
@@ -77,6 +78,36 @@ class UnusedTestFactoryOption(Rule):
77
78
  expected_count=0,
78
79
  public=True,
79
80
  ),
81
+ RuleExample(
82
+ example_id="unused-callable-option",
83
+ scenario="callable-default",
84
+ title="Use an unexercised local dependency directly",
85
+ outcome=ExampleOutcome.MATCH,
86
+ files=(
87
+ ExampleFile.python(
88
+ "tests/test_widget.py",
89
+ "def _read():\n return 'value'\n\ndef _make_widget(*, read=_read):\n return Widget(read=read)\n\n_make_widget()\n",
90
+ ),
91
+ ),
92
+ focus_path=PurePosixPath("tests/test_widget.py"),
93
+ expected_count=1,
94
+ public=True,
95
+ ),
96
+ RuleExample(
97
+ example_id="exercised-callable-option",
98
+ scenario="callable-default",
99
+ title="Keep a dependency that a caller replaces",
100
+ outcome=ExampleOutcome.NO_MATCH,
101
+ files=(
102
+ ExampleFile.python(
103
+ "tests/test_widget.py",
104
+ "def _read():\n return 'value'\n\ndef _make_widget(*, read=_read):\n return Widget(read=read)\n\n_make_widget(read=other)\n",
105
+ ),
106
+ ),
107
+ focus_path=PurePosixPath("tests/test_widget.py"),
108
+ expected_count=0,
109
+ public=True,
110
+ ),
80
111
  ),
81
112
  )
82
113
  description = documentation.summary
@@ -173,6 +204,27 @@ def _factory_findings(
173
204
  ),
174
205
  )
175
206
  )
207
+ for argument, default in _factory_options(function):
208
+ if (
209
+ argument.arg in supplied
210
+ or not isinstance(default, ast.Name)
211
+ or not _is_stable_local_callable(tree, function, default.id, node_index=node_index)
212
+ or is_suppressed(lines, argument.lineno, code)
213
+ ):
214
+ continue
215
+ findings.append(
216
+ Diagnostic(
217
+ path=path,
218
+ line=argument.lineno,
219
+ col=argument.col_offset + 1,
220
+ code=code,
221
+ severity=Severity.WARNING,
222
+ message=(
223
+ f"No direct caller in this file supplies `{function.name}.{argument.arg}`; use the stable local "
224
+ f"callable `{default.id}` in the factory instead of exposing an unused option. Retain it if external callers need it."
225
+ ),
226
+ )
227
+ )
176
228
  return findings
177
229
 
178
230
 
@@ -258,18 +310,49 @@ def _factory_argument_is_invariant(argument: ast.arg, default: ast.Constant, bou
258
310
  )
259
311
 
260
312
 
261
- def _literal_factory_options(function: ast.FunctionDef) -> list[tuple[ast.arg, ast.Constant]]:
262
- options: list[tuple[ast.arg, ast.Constant]] = []
313
+ def _factory_options(function: ast.FunctionDef) -> list[tuple[ast.arg, ast.expr]]:
263
314
  positional = [*function.args.posonlyargs, *function.args.args]
264
315
  defaults = [
265
316
  *zip(positional[len(positional) - len(function.args.defaults) :], function.args.defaults, strict=True),
266
317
  *zip(function.args.kwonlyargs, function.args.kw_defaults, strict=True),
267
318
  ]
268
319
  used = {node.id for node in nodes(function, ast.Name) if isinstance(node.ctx, ast.Load)}
269
- for argument, default in defaults:
270
- if argument.arg not in used or not isinstance(default, ast.Constant):
271
- continue
272
- if default.value is not None and not isinstance(default.value, (str, bytes, int, float)):
273
- continue
274
- options.append((argument, default))
275
- return options
320
+ return [(argument, default) for argument, default in defaults if argument.arg in used and default is not None]
321
+
322
+
323
+ def _literal_factory_options(function: ast.FunctionDef) -> list[tuple[ast.arg, ast.Constant]]:
324
+ return [
325
+ (argument, default)
326
+ for argument, default in _factory_options(function)
327
+ if isinstance(default, ast.Constant)
328
+ and (default.value is None or isinstance(default.value, (str, bytes, int, float)))
329
+ ]
330
+
331
+
332
+ def _is_stable_local_callable(
333
+ tree: ast.Module,
334
+ factory: ast.FunctionDef,
335
+ name: str,
336
+ *,
337
+ node_index: NodeIndex | None = None,
338
+ ) -> bool:
339
+ if not any(
340
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
341
+ and node.name == name
342
+ and node.lineno < factory.lineno
343
+ and not node.decorator_list
344
+ for node in tree.body
345
+ ) or not _has_unambiguous_factory_name(tree, name, node_index=node_index):
346
+ return False
347
+ return not (
348
+ any(node.id == name and not isinstance(node.ctx, ast.Load) for node in nodes(tree, ast.Name, index=node_index))
349
+ or any(
350
+ not isinstance(node.ctx, ast.Load) and any(child.id == name for child in nodes(node.value, ast.Name))
351
+ for node in nodes(tree, ast.Attribute, index=node_index)
352
+ )
353
+ or any(
354
+ isinstance(node.value, str)
355
+ and (node.value.rsplit(".", 1)[-1] == name or factory.name in node.value.split("."))
356
+ for node in nodes(tree, ast.Constant, index=node_index)
357
+ )
358
+ )