sarj-python-lint 0.83.1__tar.gz → 0.84.1__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 (137) hide show
  1. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/__main__.py +8 -1
  4. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_registry.py +10 -0
  5. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +1 -1
  6. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +1 -1
  7. sarj_python_lint-0.84.1/src/sarj_python_lint/rules/fastapi_class_router_contract.py +191 -0
  8. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +1 -1
  9. sarj_python_lint-0.84.1/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +94 -0
  10. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +2 -2
  11. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +1 -1
  12. sarj_python_lint-0.84.1/src/sarj_python_lint/rules/no_vague_annotations.py +173 -0
  13. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +1 -1
  14. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/phase_label_comment.py +1 -1
  15. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_class_row.py +1 -1
  16. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +35 -63
  17. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +9 -21
  18. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +2 -2
  19. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_str_enum.py +25 -8
  20. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +1 -1
  21. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +1 -1
  22. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +1 -1
  23. sarj_python_lint-0.84.1/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +157 -0
  24. sarj_python_lint-0.84.1/src/sarj_python_lint/rules/require_typed_http_test_response.py +118 -0
  25. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/restated_test_docstring.py +2 -2
  26. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/.gitignore +0 -0
  27. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/LICENSE +0 -0
  28. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/README.md +0 -0
  29. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/__init__.py +0 -0
  30. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  31. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_filesystem.py +0 -0
  32. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_python_target.py +0 -0
  33. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  34. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_secret_names.py +0 -0
  35. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_version.py +0 -0
  36. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/py.typed +0 -0
  37. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/ratchet.py +0 -0
  38. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rule_base.py +0 -0
  39. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  40. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  41. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  42. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  43. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  44. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  45. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  46. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  47. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  48. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  49. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  50. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  51. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  52. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  53. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  54. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  55. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  56. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  57. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  58. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  59. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  60. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  61. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  62. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  63. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  64. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  65. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  66. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  67. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  68. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  69. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  70. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  71. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  72. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  73. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  74. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  75. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  76. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  77. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  78. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  79. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  80. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  81. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  82. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  83. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  84. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  85. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  86. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  87. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  88. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  89. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  90. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  91. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  92. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  93. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  94. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  95. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  96. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  97. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  98. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  99. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  100. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  101. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  102. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  103. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  104. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  105. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  106. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  107. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  108. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  109. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  110. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  111. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  112. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  113. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  114. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  115. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  116. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  117. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  118. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  119. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  120. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  121. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  122. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  123. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  124. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  125. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  126. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  127. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  128. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  129. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  130. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  131. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  132. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  133. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  134. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  135. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  136. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  137. {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.83.1
3
+ Version: 0.84.1
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.83.1"
3
+ version = "0.84.1"
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" }]
@@ -209,7 +209,7 @@ def _suppressed_diagnostics(
209
209
 
210
210
 
211
211
  def _suppressed_codes(codes: dict[str, set[Severity]]) -> set[tuple[str, Severity]]:
212
- return {
212
+ suppressed = {
213
213
  (generic, generic_severity)
214
214
  for specific, generics in _DIAGNOSTIC_PRECEDENCE.items()
215
215
  if specific in codes
@@ -217,6 +217,13 @@ def _suppressed_codes(codes: dict[str, set[Severity]]) -> set[tuple[str, Severit
217
217
  for generic_severity in codes.get(generic, set())
218
218
  if generic_severity is Severity.WARNING or Severity.ERROR in codes[specific]
219
219
  }
220
+ # The promoted signature-restatement rules now block. Keep their errors and
221
+ # drop only the lower-severity typed-docstring twin at the same owner.
222
+ if Severity.WARNING in codes.get("SARJ092", set()) and any(
223
+ Severity.ERROR in codes.get(generic, set()) for generic in ("SARJ086", "SARJ087")
224
+ ):
225
+ suppressed.add(("SARJ092", Severity.WARNING))
226
+ return suppressed
220
227
 
221
228
 
222
229
  def _function_signature_owner_locations(source: str) -> dict[int, _OwnerLocation]:
@@ -15,6 +15,7 @@ from sarj_python_lint.rules.docstring_returns_restate_signature import (
15
15
  )
16
16
  from sarj_python_lint.rules.excessive_commentary import ExcessiveCommentary
17
17
  from sarj_python_lint.rules.fakes_in_shared_location import FakesInSharedLocation
18
+ from sarj_python_lint.rules.fastapi_class_router_contract import FastapiClassRouterContract
18
19
  from sarj_python_lint.rules.fastapi_explicit_openapi_contract import FastapiExplicitOpenapiContract
19
20
  from sarj_python_lint.rules.iac_source_coupled_test import IacSourceCoupledTest
20
21
  from sarj_python_lint.rules.invalid_pydantic_field_default import (
@@ -51,6 +52,7 @@ from sarj_python_lint.rules.no_generic_single_export_module import NoGenericSing
51
52
  from sarj_python_lint.rules.no_hidden_constructor_fallback import (
52
53
  NoHiddenConstructorFallback,
53
54
  )
55
+ from sarj_python_lint.rules.no_invalid_argument_name_suppression import NoInvalidArgumentNameSuppression
54
56
  from sarj_python_lint.rules.no_nested_pydantic_field_validator import NoNestedPydanticFieldValidator
55
57
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
56
58
  from sarj_python_lint.rules.no_positional_psycopg_row_escape import NoPositionalPsycopgRowEscape
@@ -75,6 +77,7 @@ from sarj_python_lint.rules.no_unique_violation_message_match import (
75
77
  NoUniqueViolationMessageMatch,
76
78
  )
77
79
  from sarj_python_lint.rules.no_unnecessary_docstring import NoUnnecessaryDocstring
80
+ from sarj_python_lint.rules.no_vague_annotations import NoVagueAnnotations
78
81
  from sarj_python_lint.rules.no_vague_suppression_description import (
79
82
  NoVagueSuppressionDescription,
80
83
  )
@@ -147,9 +150,11 @@ from sarj_python_lint.rules.require_port_for_service import RequirePortForServic
147
150
  from sarj_python_lint.rules.require_pydantic_for_external_json import (
148
151
  RequirePydanticForExternalJson,
149
152
  )
153
+ from sarj_python_lint.rules.require_pydantic_for_structured_payload import RequirePydanticForStructuredPayload
150
154
  from sarj_python_lint.rules.require_pydantic_ordinal_lower_bound import (
151
155
  RequirePydanticOrdinalLowerBound,
152
156
  )
157
+ from sarj_python_lint.rules.require_typed_http_test_response import RequireTypedHttpTestResponse
153
158
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
154
159
  from sarj_python_lint.rules.stepdown import Stepdown
155
160
  from sarj_python_lint.rules.store_get_delegates_to_bulk_read import StoreGetDelegatesToBulkRead
@@ -185,6 +190,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
185
190
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
186
191
  PydanticAtBoundaries.id: PydanticAtBoundaries,
187
192
  FastapiExplicitOpenapiContract.id: FastapiExplicitOpenapiContract,
193
+ FastapiClassRouterContract.id: FastapiClassRouterContract,
188
194
  FakesInSharedLocation.id: FakesInSharedLocation,
189
195
  PreferConstantTimeSecretCompare.id: PreferConstantTimeSecretCompare,
190
196
  NoSecretInLog.id: NoSecretInLog,
@@ -222,6 +228,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
222
228
  NoRedundantModuleAliasExports.id: NoRedundantModuleAliasExports,
223
229
  NoHiddenConstructorFallback.id: NoHiddenConstructorFallback,
224
230
  NoFileLevelEscapeHatchSuppression.id: NoFileLevelEscapeHatchSuppression,
231
+ NoInvalidArgumentNameSuppression.id: NoInvalidArgumentNameSuppression,
225
232
  NoFastapiOnEvent.id: NoFastapiOnEvent,
226
233
  NoTautologicalExpect.id: NoTautologicalExpect,
227
234
  PreferLibraryFake.id: PreferLibraryFake,
@@ -258,6 +265,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
258
265
  NoTypedDocSections.id: NoTypedDocSections,
259
266
  NoUnnecessaryDocstring.id: NoUnnecessaryDocstring,
260
267
  PreferNominalIdTypes.id: PreferNominalIdTypes,
268
+ NoVagueAnnotations.id: NoVagueAnnotations,
261
269
  NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
262
270
  NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
263
271
  ProductionDerivedTestCases.id: ProductionDerivedTestCases,
@@ -266,6 +274,8 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
266
274
  NoPositionalPsycopgRowEscape.id: NoPositionalPsycopgRowEscape,
267
275
  NoPsycopgExecutionOutsideInjectedOwner.id: NoPsycopgExecutionOutsideInjectedOwner,
268
276
  RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
277
+ RequirePydanticForStructuredPayload.id: RequirePydanticForStructuredPayload,
278
+ RequireTypedHttpTestResponse.id: RequireTypedHttpTestResponse,
269
279
  RequireNoDecodeForSplittingSettingsField.id: RequireNoDecodeForSplittingSettingsField,
270
280
  NoVagueSuppressionDescription.id: NoVagueSuppressionDescription,
271
281
  NoWholeRequestResponsePayloadInLog.id: NoWholeRequestResponsePayloadInLog,
@@ -181,7 +181,7 @@ class DocstringArgsRestateSignature(Rule):
181
181
  f"`{node.name}` has an Args section whose entries only repeat matching parameter names or types; "
182
182
  "remove that section or document a constraint not evident from the signature."
183
183
  ),
184
- severity=Severity.WARNING,
184
+ severity=Severity.ERROR,
185
185
  )
186
186
  )
187
187
 
@@ -252,7 +252,7 @@ class DocstringReturnsRestateSignature(Rule):
252
252
  f"`{node.name}` {section.name} section only repeats its callable name or annotated result type; "
253
253
  "remove that section or document result semantics not expressed by the signature."
254
254
  ),
255
- severity=Severity.WARNING,
255
+ severity=Severity.ERROR,
256
256
  )
257
257
  )
258
258
 
@@ -0,0 +1,191 @@
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
+ parse_or_none,
17
+ )
18
+ from sarj_python_lint.rules._fastapi import FastapiIndex, Route, flat_name
19
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+
25
+
26
+ _NO_BODY = frozenset({204, 304})
27
+ _NON_JSON_RESPONSES = frozenset(
28
+ {"FileResponse", "HTMLResponse", "PlainTextResponse", "RedirectResponse", "StreamingResponse"}
29
+ )
30
+ _TOP_LEVEL_COLLECTIONS = frozenset(
31
+ {"dict", "Dict", "list", "List", "Mapping", "MutableMapping", "set", "Set", "tuple", "Tuple", "Sequence"}
32
+ )
33
+ _SCALARS = frozenset({"bool", "bytes", "float", "int", "str"})
34
+
35
+
36
+ @final
37
+ class FastapiClassRouterContract(Rule):
38
+ id = "fastapi-class-router-contract"
39
+ code = "SARJ451"
40
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
41
+ summary="FastAPI routers use injected `*Router.build()` owners and explicit named object response models.",
42
+ rationale=(
43
+ "Class-owned construction gives dependencies one reviewable composition boundary, while explicit object models "
44
+ "keep success envelopes stable and generated clients precise."
45
+ ),
46
+ remediation=(
47
+ "Inject dependencies through `*Router.__init__`, create and return a local `APIRouter` from `build()`, and declare "
48
+ "`response_model=NamedResponse` with a matching named return annotation."
49
+ ),
50
+ category=RuleCategory.ARCHITECTURE,
51
+ autofix=AutofixPolicy.NONE,
52
+ limitations=(
53
+ "Tests, generated code, hidden routes, 204/304 responses, and explicit stream/file/HTML/text/redirect responses are excluded.",
54
+ ),
55
+ examples=(
56
+ RuleExample(
57
+ example_id="module-router",
58
+ title="A module owns an unscoped router",
59
+ outcome=ExampleOutcome.MATCH,
60
+ files=(ExampleFile.python("app/routes.py", "from fastapi import APIRouter\nrouter = APIRouter()\n"),),
61
+ focus_path=PurePosixPath("app/routes.py"),
62
+ expected_count=1,
63
+ public=True,
64
+ ),
65
+ RuleExample(
66
+ example_id="class-router",
67
+ title="A class builds and returns its router",
68
+ outcome=ExampleOutcome.NO_MATCH,
69
+ files=(
70
+ ExampleFile.python(
71
+ "app/routes.py",
72
+ "from fastapi import APIRouter\nclass ItemRouter:\n def __init__(self, service: Service) -> None:\n self._service = service\n def build(self) -> APIRouter:\n router = APIRouter()\n return router\n",
73
+ ),
74
+ ),
75
+ focus_path=PurePosixPath("app/routes.py"),
76
+ expected_count=0,
77
+ public=True,
78
+ ),
79
+ ),
80
+ )
81
+ description = documentation.summary
82
+
83
+ @override
84
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
85
+ if is_test_path(path) or is_generated(path, source):
86
+ return []
87
+ tree = parse_or_none(path, source)
88
+ if tree is None:
89
+ return []
90
+ index = FastapiIndex(tree, path=path)
91
+ parents = _parents(tree)
92
+ findings = _router_construction_findings(path, tree, index, parents)
93
+ for function in (node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))):
94
+ routes = tuple(route for route in index.routes(function) if not route.is_hidden)
95
+ for route in routes:
96
+ problem = _response_problem(function, route, index)
97
+ if problem is not None:
98
+ findings.append(Diagnostic(path, function.lineno, function.col_offset + 1, self.code, problem))
99
+ return sorted(findings, key=lambda finding: (finding.line, finding.col, finding.message))
100
+
101
+
102
+ def _router_construction_findings(
103
+ path: Path, tree: ast.Module, index: FastapiIndex, parents: dict[int, ast.AST]
104
+ ) -> list[Diagnostic]:
105
+ findings: list[Diagnostic] = []
106
+ for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
107
+ if index.canonical(call.func) != "APIRouter":
108
+ continue
109
+ function = _ancestor(call, parents, (ast.FunctionDef, ast.AsyncFunctionDef))
110
+ cls = _ancestor(call, parents, ast.ClassDef)
111
+ if (
112
+ isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef))
113
+ and isinstance(cls, ast.ClassDef)
114
+ and function.name == "build"
115
+ and cls.name.endswith("Router")
116
+ and _returns_local_router(function, call)
117
+ ):
118
+ continue
119
+ findings.append(
120
+ Diagnostic(
121
+ path,
122
+ call.lineno,
123
+ call.col_offset + 1,
124
+ FastapiClassRouterContract.code,
125
+ "construct APIRouter only inside `*Router.build()` and return that local router",
126
+ )
127
+ )
128
+ return findings
129
+
130
+
131
+ def _returns_local_router(function: ast.FunctionDef | ast.AsyncFunctionDef, call: ast.Call) -> bool:
132
+ names = {
133
+ target.id
134
+ for node in function.body
135
+ if isinstance(node, (ast.Assign, ast.AnnAssign))
136
+ for target in _assignment_targets(node)
137
+ if isinstance(target, ast.Name) and getattr(node, "value", None) is call
138
+ }
139
+ return bool(names) and any(
140
+ isinstance(node, ast.Return) and isinstance(node.value, ast.Name) and node.value.id in names
141
+ for node in function.body
142
+ )
143
+
144
+
145
+ def _assignment_targets(node: ast.Assign | ast.AnnAssign) -> list[ast.expr]:
146
+ return list(node.targets) if isinstance(node, ast.Assign) else [node.target]
147
+
148
+
149
+ def _response_problem(
150
+ function: ast.FunctionDef | ast.AsyncFunctionDef, route: Route, index: FastapiIndex
151
+ ) -> str | None:
152
+ keywords = route.keywords
153
+ status = keywords.get("status_code")
154
+ if isinstance(status, ast.Constant) and status.value in _NO_BODY:
155
+ return None
156
+ response_class = keywords.get("response_class") or route.inherited_response_class
157
+ if response_class is not None and flat_name(response_class) in _NON_JSON_RESPONSES:
158
+ return None
159
+ response_model = keywords.get("response_model")
160
+ if response_model is None or (isinstance(response_model, ast.Constant) and response_model.value is None):
161
+ return "JSON operation requires explicit `response_model=NamedResponse`"
162
+ annotation = index.resolve_annotation(function.returns)
163
+ if annotation is None:
164
+ return "JSON operation requires a named response return annotation matching response_model"
165
+ if _is_collection_or_scalar(annotation):
166
+ return (
167
+ "successful JSON responses must use a named object envelope, not a top-level collection, mapping, or scalar"
168
+ )
169
+ if flat_name(annotation) != flat_name(response_model):
170
+ return "response_model must match the named response return annotation"
171
+ return None
172
+
173
+
174
+ def _is_collection_or_scalar(annotation: ast.expr) -> bool:
175
+ target = annotation.value if isinstance(annotation, ast.Subscript) else annotation
176
+ return flat_name(target) in _TOP_LEVEL_COLLECTIONS | _SCALARS
177
+
178
+
179
+ def _parents(tree: ast.Module) -> dict[int, ast.AST]:
180
+ return {id(child): parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)}
181
+
182
+
183
+ def _ancestor(
184
+ node: ast.AST, parents: dict[int, ast.AST], kind: type[ast.AST] | tuple[type[ast.AST], ...]
185
+ ) -> ast.AST | None:
186
+ current = parents.get(id(node))
187
+ while current is not None:
188
+ if isinstance(current, kind):
189
+ return current
190
+ current = parents.get(id(current))
191
+ return None
@@ -158,7 +158,7 @@ class NoCopiedInheritedDocstring(Rule):
158
158
  f"Docstring has the same cleaned content as {parent.name}.{name}'s. Remove it only if no "
159
159
  "consumer requires the override's direct `__doc__`; otherwise retain it with a narrow rationale."
160
160
  ),
161
- severity=Severity.WARNING,
161
+ severity=Severity.ERROR,
162
162
  )
163
163
  )
164
164
 
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+ import re
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ ColumnEncoding,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ )
17
+ from sarj_python_lint.rules._paths import is_generated
18
+ from sarj_python_lint.rules._suppression_comments import scan_comments_or_none
19
+
20
+
21
+ if TYPE_CHECKING:
22
+ from pathlib import Path
23
+
24
+
25
+ _FORBIDDEN = re.compile(r"(?:^|[,:\s\[])\s*(?:N803|invalid-argument-name)(?=$|[,\]\s])", re.IGNORECASE)
26
+
27
+
28
+ @final
29
+ class NoInvalidArgumentNameSuppression(Rule):
30
+ id = "no-invalid-argument-name-suppression"
31
+ code = "SARJ448"
32
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
33
+ summary="External parameter spelling must use framework aliases instead of disabling snake_case naming.",
34
+ rationale="Naming suppressions leak an external wire vocabulary into Python APIs and normalize future exceptions.",
35
+ remediation=(
36
+ "Keep the Python parameter snake_case and preserve the external spelling with FastAPI `Query`/`Path` aliases "
37
+ "or Pydantic `Field` aliases."
38
+ ),
39
+ category=RuleCategory.MAINTAINABILITY,
40
+ limitations=("Generated source is excluded.",),
41
+ examples=(
42
+ RuleExample(
43
+ example_id="camel-case-parameter-suppression",
44
+ title="A wire parameter disables Python naming",
45
+ outcome=ExampleOutcome.MATCH,
46
+ files=(
47
+ ExampleFile.python(
48
+ "app/routes.py",
49
+ "def route(businessFunction: str): # ruff: ignore[invalid-argument-name]\n ...\n",
50
+ ),
51
+ ),
52
+ focus_path=PurePosixPath("app/routes.py"),
53
+ expected_count=1,
54
+ public=True,
55
+ ),
56
+ RuleExample(
57
+ example_id="snake-case-wire-alias",
58
+ title="A framework alias preserves external spelling",
59
+ outcome=ExampleOutcome.NO_MATCH,
60
+ files=(
61
+ ExampleFile.python(
62
+ "app/routes.py",
63
+ "from fastapi import Query\ndef route(business_function: str = Query(alias='businessFunction')): ...\n",
64
+ ),
65
+ ),
66
+ focus_path=PurePosixPath("app/routes.py"),
67
+ expected_count=0,
68
+ public=True,
69
+ ),
70
+ ),
71
+ )
72
+ description = documentation.summary
73
+
74
+ @override
75
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
76
+ if is_generated(path, source):
77
+ return []
78
+ comments = scan_comments_or_none(source)
79
+ if comments is None:
80
+ return []
81
+ return [
82
+ Diagnostic(
83
+ path=path,
84
+ line=comment.line,
85
+ col=comment.col,
86
+ code=self.code,
87
+ message=(
88
+ "do not suppress Python argument naming; use a snake_case parameter and an explicit wire alias"
89
+ ),
90
+ column_encoding=ColumnEncoding.CODEPOINTS,
91
+ )
92
+ for comment in comments
93
+ if _FORBIDDEN.search(comment.body)
94
+ ]
@@ -187,7 +187,7 @@ class NoRepeatedStringLiteral(Rule):
187
187
  f"structured string literal {_preview(value)} duplicates module constant "
188
188
  f"`{constant_name}` — reuse the canonical constant so the copies cannot drift."
189
189
  ),
190
- severity=Severity.WARNING,
190
+ severity=Severity.ERROR,
191
191
  )
192
192
  for node in function_nodes
193
193
  )
@@ -209,7 +209,7 @@ class NoRepeatedStringLiteral(Rule):
209
209
  f"callable scopes (first use at line {first.lineno}) — reuse a named binding "
210
210
  "when they share ownership, or suppress with an independent-ownership rationale."
211
211
  ),
212
- severity=Severity.WARNING,
212
+ severity=Severity.ERROR,
213
213
  )
214
214
  for node in repeats
215
215
  )
@@ -204,7 +204,7 @@ class NoRepeatedTestBody(Rule):
204
204
  col=group[1].node.col_offset + 1,
205
205
  code=self.code,
206
206
  message=_message(group),
207
- severity=Severity.WARNING,
207
+ severity=Severity.ERROR,
208
208
  )
209
209
  for group in groups
210
210
  ]
@@ -0,0 +1,173 @@
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
+ is_suppressed,
17
+ parse_or_none,
18
+ )
19
+ from sarj_python_lint.rules._imports import ImportIndex
20
+ from sarj_python_lint.rules._paths import is_generated
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ _TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
28
+ _MAPPING_SOURCES = frozenset({"collections.abc", "typing"})
29
+ _MAPPING_NAMES = frozenset({"dict", "Dict", "Mapping", "MutableMapping"})
30
+ _MAPPING_ARGUMENT_COUNT = 2
31
+
32
+
33
+ @final
34
+ class NoVagueAnnotations(Rule):
35
+ id = "no-vague-annotations"
36
+ code = "SARJ447"
37
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
38
+ summary="Annotations must not erase domain shape with `object` or `dict[str, Any]`.",
39
+ rationale=(
40
+ "Vague annotations move schema mistakes from type checking to runtime and conceal the fields a caller may use."
41
+ ),
42
+ remediation=(
43
+ "Use a named Pydantic model, dataclass, TypedDict, Protocol, TypeVar, domain type, or an explicit recursive "
44
+ "JSON value type at a serialization boundary."
45
+ ),
46
+ category=RuleCategory.CORRECTNESS,
47
+ autofix=AutofixPolicy.NONE,
48
+ limitations=("Generated and vendored sources are excluded; exact local suppressions remain auditable.",),
49
+ examples=(
50
+ RuleExample(
51
+ example_id="vague-mapping-annotation",
52
+ title="An open mapping erases the record schema",
53
+ outcome=ExampleOutcome.MATCH,
54
+ files=(
55
+ ExampleFile.python(
56
+ "app/offers.py", "from typing import Any\ndef details() -> dict[str, Any]: ...\n"
57
+ ),
58
+ ),
59
+ focus_path=PurePosixPath("app/offers.py"),
60
+ expected_count=1,
61
+ public=True,
62
+ ),
63
+ RuleExample(
64
+ example_id="named-model-annotation",
65
+ title="A named model preserves the record schema",
66
+ outcome=ExampleOutcome.NO_MATCH,
67
+ files=(ExampleFile.python("app/offers.py", "def details() -> OfferDetails: ...\n"),),
68
+ focus_path=PurePosixPath("app/offers.py"),
69
+ expected_count=0,
70
+ public=True,
71
+ ),
72
+ ),
73
+ )
74
+ description = documentation.summary
75
+
76
+ @override
77
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
78
+ if is_generated(path, source) or _is_vendor_path(path):
79
+ return []
80
+ tree = parse_or_none(path, source)
81
+ if tree is None:
82
+ return []
83
+ imports = ImportIndex.from_tree(tree)
84
+ lines = source.splitlines()
85
+ findings: list[Diagnostic] = []
86
+ for annotation in _annotations(tree):
87
+ problem = _vague_problem(annotation, imports)
88
+ if problem is None or is_suppressed(lines, annotation.lineno, self.code):
89
+ continue
90
+ findings.append(
91
+ Diagnostic(
92
+ path=path,
93
+ line=annotation.lineno,
94
+ col=annotation.col_offset + 1,
95
+ code=self.code,
96
+ message=f"vague annotation `{problem}` erases its domain schema; use a named precise type",
97
+ )
98
+ )
99
+ return sorted(findings, key=lambda finding: (finding.line, finding.col))
100
+
101
+
102
+ def _annotations(tree: ast.Module) -> list[ast.expr]:
103
+ return [annotation for node in ast.walk(tree) if (annotation := _node_annotation(node)) is not None]
104
+
105
+
106
+ def _node_annotation(node: ast.AST) -> ast.expr | None:
107
+ match node:
108
+ case (
109
+ ast.arg(annotation=annotation)
110
+ | ast.AnnAssign(annotation=annotation)
111
+ | ast.FunctionDef(returns=annotation)
112
+ | ast.AsyncFunctionDef(returns=annotation)
113
+ ):
114
+ return annotation
115
+ case ast.TypeAlias(value=value):
116
+ return value
117
+ case _:
118
+ return None
119
+
120
+
121
+ def _vague_problem(annotation: ast.expr, imports: ImportIndex) -> str | None:
122
+ annotation = _parse_string_annotation(annotation)
123
+ for node in ast.walk(annotation):
124
+ if _is_object(node, imports):
125
+ return "object"
126
+ if _is_open_mapping(node, imports):
127
+ return ast.unparse(node)
128
+ return None
129
+
130
+
131
+ def _parse_string_annotation(annotation: ast.expr) -> ast.expr:
132
+ if not isinstance(annotation, ast.Constant) or not isinstance(annotation.value, str):
133
+ return annotation
134
+ try:
135
+ return ast.parse(annotation.value, mode="eval").body
136
+ except SyntaxError:
137
+ return annotation
138
+
139
+
140
+ def _is_object(node: ast.AST, imports: ImportIndex) -> bool:
141
+ return isinstance(node, ast.expr) and (
142
+ (isinstance(node, ast.Name) and node.id == "object" and imports.builtin_is_unshadowed("object"))
143
+ or imports.resolves(node, sources=frozenset({"builtins"}), symbol="object")
144
+ )
145
+
146
+
147
+ def _is_open_mapping(node: ast.AST, imports: ImportIndex) -> bool:
148
+ if (
149
+ not isinstance(node, ast.Subscript)
150
+ or not isinstance(node.slice, ast.Tuple)
151
+ or len(node.slice.elts) != _MAPPING_ARGUMENT_COUNT
152
+ ):
153
+ return False
154
+ target = node.value
155
+ mapping = (isinstance(target, ast.Name) and target.id == "dict" and imports.builtin_is_unshadowed("dict")) or any(
156
+ imports.resolves(target, sources=_MAPPING_SOURCES, symbol=name) for name in _MAPPING_NAMES
157
+ )
158
+ if not mapping:
159
+ return False
160
+ key, value = node.slice.elts
161
+ return _is_builtin_str(key, imports) and _is_any_or_object(value, imports)
162
+
163
+
164
+ def _is_builtin_str(node: ast.expr, imports: ImportIndex) -> bool:
165
+ return isinstance(node, ast.Name) and node.id == "str" and imports.builtin_is_unshadowed("str")
166
+
167
+
168
+ def _is_any_or_object(node: ast.expr, imports: ImportIndex) -> bool:
169
+ return _is_object(node, imports) or imports.resolves(node, sources=_TYPING_SOURCES, symbol="Any")
170
+
171
+
172
+ def _is_vendor_path(path: Path) -> bool:
173
+ return any(part.casefold() in {"vendor", "vendored", "third_party"} for part in path.parts)
@@ -123,7 +123,7 @@ class NoVagueSuppressionDescription(Rule):
123
123
  message=(
124
124
  f'Suppression reason "{vague_reason}" is generic; name the concrete mismatch or safety invariant.'
125
125
  ),
126
- severity=Severity.WARNING,
126
+ severity=Severity.ERROR,
127
127
  column_encoding=ColumnEncoding.CODEPOINTS,
128
128
  )
129
129
  )
@@ -112,7 +112,7 @@ class TestPhaseLabelComment(Rule):
112
112
  f"Bare test phase comment {body.strip()!r} narrates structure; delete it or replace it "
113
113
  "with non-obvious rationale."
114
114
  ),
115
- severity=Severity.WARNING,
115
+ severity=Severity.ERROR,
116
116
  column_encoding=ColumnEncoding.CODEPOINTS,
117
117
  )
118
118
  for line, col, body in standalone
@@ -123,7 +123,7 @@ class PreferClassRow(Rule):
123
123
  line=cursor.factory.lineno,
124
124
  col=cursor.factory.col_offset + 1,
125
125
  code=self.code,
126
- severity=Severity.WARNING,
126
+ severity=Severity.ERROR,
127
127
  message=(
128
128
  f"this dict row is immediately converted to `{model}`; use `class_row({model})` to "
129
129
  "construct it during fetch"