sarj-python-lint 0.67.13__tar.gz → 0.68.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 (106) hide show
  1. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_registry.py +12 -0
  4. sarj_python_lint-0.68.0/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +169 -0
  5. sarj_python_lint-0.68.0/src/sarj_python_lint/rules/production_derived_test_cases.py +191 -0
  6. sarj_python_lint-0.68.0/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +446 -0
  7. sarj_python_lint-0.68.0/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +176 -0
  8. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/.gitignore +0 -0
  9. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/LICENSE +0 -0
  10. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/README.md +0 -0
  11. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  29. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  35. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  36. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  37. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  38. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  39. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  40. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  42. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  43. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  44. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  46. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  47. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  48. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  49. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  50. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  51. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  52. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  53. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  54. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  55. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  56. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  57. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  58. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  60. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  61. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  62. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  63. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  64. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  65. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  66. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  67. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  68. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  69. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  70. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  71. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  72. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  73. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  74. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  75. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  76. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  77. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  78. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  79. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  80. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  81. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  82. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  83. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  84. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  85. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  86. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  87. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  88. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  89. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  90. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  91. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  92. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  93. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  94. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  95. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  96. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  97. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  98. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  99. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  100. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  101. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  102. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  103. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  104. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  105. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  106. {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.67.13
3
+ Version: 0.68.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.67.13"
3
+ version = "0.68.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" }]
@@ -24,6 +24,9 @@ from sarj_python_lint.rules.invalid_pydantic_field_default import (
24
24
  )
25
25
  from sarj_python_lint.rules.kwarg_heavy_construction_in_test import KwargHeavyConstructionInTest
26
26
  from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
27
+ from sarj_python_lint.rules.negative_only_http_status_assertion import (
28
+ NegativeOnlyHttpStatusAssertion,
29
+ )
27
30
  from sarj_python_lint.rules.no_aggregation_in_store_query import (
28
31
  NoAggregationInStoreQuery,
29
32
  )
@@ -105,6 +108,7 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
105
108
  )
106
109
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
107
110
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
111
+ from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
108
112
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
109
113
  from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
110
114
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
@@ -113,6 +117,9 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
113
117
  RequireKeywordOnlySwapProneParams,
114
118
  )
115
119
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
120
+ from sarj_python_lint.rules.require_pydantic_for_external_json import (
121
+ RequirePydanticForExternalJson,
122
+ )
116
123
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
117
124
  from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
118
125
  from sarj_python_lint.rules.stepdown import Stepdown
@@ -121,6 +128,7 @@ from sarj_python_lint.rules.store_insert_requires_on_conflict import (
121
128
  )
122
129
  from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarration
123
130
  from sarj_python_lint.rules.trivially_true_assertion import TriviallyTrueAssertion
131
+ from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
124
132
  from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
125
133
 
126
134
 
@@ -157,6 +165,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
157
165
  NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
158
166
  PreferMatchAssertNever.id: PreferMatchAssertNever,
159
167
  RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
168
+ RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
160
169
  NoFileLevelSuppression.id: NoFileLevelSuppression,
161
170
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
162
171
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
@@ -204,6 +213,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
204
213
  NoTypedDocSections.id: NoTypedDocSections,
205
214
  PreferNominalIdTypes.id: PreferNominalIdTypes,
206
215
  NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
216
+ NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
217
+ ProductionDerivedTestCases.id: ProductionDerivedTestCases,
218
+ UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
207
219
  }
208
220
  )
209
221
 
@@ -0,0 +1,169 @@
1
+ """SARJ408 — A negative-only HTTP status assertion permits wrong non-500 responses.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_negative_only_http_status_assertion.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import PurePosixPath
10
+ from typing import TYPE_CHECKING, final, override
11
+
12
+ from sarj_python_lint.rule_base import (
13
+ AutofixPolicy,
14
+ Diagnostic,
15
+ ExampleFile,
16
+ ExampleOutcome,
17
+ Rule,
18
+ RuleCategory,
19
+ RuleDocumentation,
20
+ RuleExample,
21
+ Severity,
22
+ parse_or_none,
23
+ )
24
+ from sarj_python_lint.rules._paths import is_test_path
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterator
29
+ from pathlib import Path
30
+
31
+
32
+ _SERVER_ERROR_MIN = 500
33
+ _SERVER_ERROR_MAX = 599
34
+
35
+
36
+ def _int(node: ast.expr) -> int | None:
37
+ if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool):
38
+ return node.value
39
+ return None
40
+
41
+
42
+ def _negative_only(compare: ast.Compare) -> bool:
43
+ if len(compare.ops) != 1 or len(compare.comparators) != 1:
44
+ return False
45
+ left = compare.left
46
+ right = compare.comparators[0]
47
+ op = compare.ops[0]
48
+ match op:
49
+ case ast.NotEq():
50
+ return (_status_code(left) and _int(right) == _SERVER_ERROR_MIN) or (
51
+ _int(left) == _SERVER_ERROR_MIN and _status_code(right)
52
+ )
53
+ case ast.Lt() if _status_code(left):
54
+ return _int(right) == _SERVER_ERROR_MIN
55
+ case ast.LtE() if _status_code(left):
56
+ return _int(right) == _SERVER_ERROR_MIN - 1
57
+ case ast.NotIn() if _status_code(left):
58
+ return _all_server_errors(right)
59
+ case _:
60
+ return False
61
+
62
+
63
+ def _status_code(node: ast.expr) -> bool:
64
+ return isinstance(node, ast.Attribute) and node.attr == "status_code"
65
+
66
+
67
+ def _all_server_errors(node: ast.expr) -> bool:
68
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "range":
69
+ values = [_int(arg) for arg in node.args]
70
+ return values == [_SERVER_ERROR_MIN, _SERVER_ERROR_MAX + 1]
71
+ if not isinstance(node, (ast.Set, ast.List, ast.Tuple)) or not node.elts:
72
+ return False
73
+ values = [_int(elt) for elt in node.elts]
74
+ return all(value is not None and _SERVER_ERROR_MIN <= value <= _SERVER_ERROR_MAX for value in values)
75
+
76
+
77
+ def _test_nodes(test: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
78
+ """Walk one collected test without attributing nested helpers to it."""
79
+ stack: list[ast.AST] = [*reversed(test.body)]
80
+ while stack:
81
+ node = stack.pop()
82
+ yield node
83
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
84
+ continue
85
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
86
+
87
+
88
+ @final
89
+ class NegativeOnlyHttpStatusAssertion(Rule):
90
+ id = "negative-only-http-status-assertion"
91
+ code = "SARJ408"
92
+ documentation = RuleDocumentation(
93
+ summary="HTTP test assertion only excludes a server error instead of identifying the intended response.",
94
+ rationale=(
95
+ "Authentication, routing, validation, and domain failures can all replace the intended response while "
96
+ "a negative-only status assertion remains green."
97
+ ),
98
+ remediation="Assert the intended exact status and the relevant domain payload or side effect.",
99
+ category=RuleCategory.TESTING,
100
+ autofix=AutofixPolicy.NONE,
101
+ limitations=(
102
+ "Only assertions on an attribute named `status_code` in collected Python tests are checked.",
103
+ "Exact status contracts, finite success sets, arbitrary `.status` attributes, and locally suppressed chaos tests are excluded.",
104
+ "The intended status cannot be inferred safely, so the rule has no autofix.",
105
+ ),
106
+ examples=(
107
+ RuleExample(
108
+ example_id="exact-validation-status",
109
+ title="Assert the intended validation response",
110
+ outcome=ExampleOutcome.NO_MATCH,
111
+ files=(
112
+ ExampleFile.python(
113
+ "tests/test_route.py",
114
+ "def test_invalid_payload(client):\n response = client.post('/items', json={})\n assert response.status_code == 422\n",
115
+ ),
116
+ ),
117
+ focus_path=PurePosixPath("tests/test_route.py"),
118
+ expected_count=0,
119
+ public=True,
120
+ ),
121
+ RuleExample(
122
+ example_id="negative-only-server-status",
123
+ title="Do not accept every non-server-error response",
124
+ outcome=ExampleOutcome.MATCH,
125
+ files=(
126
+ ExampleFile.python(
127
+ "tests/test_route.py",
128
+ "def test_invalid_payload(client):\n response = client.post('/items', json={})\n assert response.status_code != 500\n",
129
+ ),
130
+ ),
131
+ focus_path=PurePosixPath("tests/test_route.py"),
132
+ expected_count=1,
133
+ public=True,
134
+ ),
135
+ ),
136
+ )
137
+ description = documentation.summary
138
+
139
+ @override
140
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
141
+ if not is_test_path(path):
142
+ return []
143
+ tree = parse_or_none(path, source)
144
+ if tree is None:
145
+ return []
146
+ tests = {
147
+ child
148
+ for child in ast.walk(tree)
149
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
150
+ }
151
+ findings: list[Diagnostic] = []
152
+ for test in tests:
153
+ findings.extend(
154
+ Diagnostic(
155
+ path=path,
156
+ line=node.lineno,
157
+ col=node.col_offset + 1,
158
+ code=self.code,
159
+ severity=Severity.WARNING,
160
+ message=(
161
+ "this assertion proves only that the response avoided one server-error shape; assert the "
162
+ "intended exact status and, when relevant, its domain payload or side effect."
163
+ ),
164
+ )
165
+ for node in _test_nodes(test)
166
+ if isinstance(node, ast.Assert) and isinstance(node.test, ast.Compare) and _negative_only(node.test)
167
+ )
168
+ findings.sort(key=lambda finding: (finding.line, finding.col))
169
+ return findings
@@ -0,0 +1,191 @@
1
+ """SARJ409 — Imported production collections are not independent parametrized test oracles.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_production_derived_test_cases.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import PurePosixPath
10
+ from typing import TYPE_CHECKING, final, override
11
+
12
+ from sarj_python_lint.rule_base import (
13
+ AutofixPolicy,
14
+ Diagnostic,
15
+ ExampleFile,
16
+ ExampleOutcome,
17
+ Rule,
18
+ RuleCategory,
19
+ RuleDocumentation,
20
+ RuleExample,
21
+ Severity,
22
+ parse_or_none,
23
+ )
24
+ from sarj_python_lint.rules._paths import is_test_path
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from pathlib import Path
29
+
30
+
31
+ _COLLECTION_WRAPPERS = frozenset({"sorted", "set", "tuple"})
32
+ _PARAMETRIZE_CASES_INDEX = 1
33
+ _PARAMETRIZE_MIN_ARGS = 2
34
+
35
+
36
+ def _imported_bindings(tree: ast.Module) -> set[str]:
37
+ bindings: set[str] = set()
38
+ for node in tree.body:
39
+ if (
40
+ isinstance(node, ast.ImportFrom)
41
+ and node.module is not None
42
+ and not node.module.startswith(("test", "tests"))
43
+ ):
44
+ bindings.update(alias.asname or alias.name for alias in node.names if alias.name != "*")
45
+ return bindings
46
+
47
+
48
+ def _direct_imported_collection(node: ast.expr, imported: set[str]) -> ast.Name | None:
49
+ if isinstance(node, ast.Name) and node.id in imported and node.id.isupper() and not _is_registry(node.id):
50
+ return node
51
+ if isinstance(node, ast.Call) and _is_imported_collection_wrapper(node, imported):
52
+ # `list(Enum)` / `tuple(Enum)` is an intentional exhaustiveness sweep;
53
+ # only set/sorted/tuple over an ALL_CAPS collection is the risky shape.
54
+ candidate = node.args[0]
55
+ return (
56
+ candidate
57
+ if isinstance(candidate, ast.Name) and candidate.id.isupper() and not _is_registry(candidate.id)
58
+ else None
59
+ )
60
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Sub, ast.BitOr, ast.BitAnd)):
61
+ for side in (node.left, node.right):
62
+ found = _direct_imported_collection(side, imported)
63
+ if found is not None and found.id.isupper():
64
+ return found
65
+ return None
66
+
67
+
68
+ def _is_registry(name: str) -> bool:
69
+ return name == "REGISTRY" or name.endswith("_REGISTRY")
70
+
71
+
72
+ def _is_imported_collection_wrapper(node: ast.Call, imported: set[str]) -> bool:
73
+ return (
74
+ isinstance(node.func, ast.Name)
75
+ and node.func.id in _COLLECTION_WRAPPERS
76
+ and len(node.args) == 1
77
+ and isinstance(node.args[0], ast.Name)
78
+ and node.args[0].id in imported
79
+ )
80
+
81
+
82
+ def _parametrize_cases(decorator: ast.expr) -> ast.expr | None:
83
+ if not isinstance(decorator, ast.Call) or len(decorator.args) < _PARAMETRIZE_MIN_ARGS:
84
+ return None
85
+ func = decorator.func
86
+ if not isinstance(func, ast.Attribute) or func.attr != "parametrize":
87
+ return None
88
+ return decorator.args[_PARAMETRIZE_CASES_INDEX]
89
+
90
+
91
+ def _independently_asserted_collections(tree: ast.Module, imported: set[str]) -> set[str]:
92
+ asserted: set[str] = set()
93
+ for node in ast.walk(tree):
94
+ if not isinstance(node, ast.Assert) or not isinstance(node.test, ast.Compare):
95
+ continue
96
+ compare = node.test
97
+ if len(compare.ops) != 1 or not isinstance(compare.ops[0], ast.Eq) or len(compare.comparators) != 1:
98
+ continue
99
+ for side in (compare.left, compare.comparators[0]):
100
+ if isinstance(side, ast.Name) and side.id in imported:
101
+ asserted.add(side.id)
102
+ return asserted
103
+
104
+
105
+ @final
106
+ class ProductionDerivedTestCases(Rule):
107
+ id = "production-derived-test-cases"
108
+ code = "SARJ409"
109
+ documentation = RuleDocumentation(
110
+ summary="Parametrized test cases come from the production collection whose membership they should protect.",
111
+ rationale=(
112
+ "Removing a production member can remove the corresponding test case too, allowing the same defect to "
113
+ "weaken both the implementation and its supposed oracle."
114
+ ),
115
+ remediation="Define an independent expected table, assert production equals it, and drive behavior from that table.",
116
+ category=RuleCategory.TESTING,
117
+ autofix=AutofixPolicy.NONE,
118
+ limitations=(
119
+ "Only direct imported collections, simple collection wrappers, and direct set expressions in pytest parametrization are checked.",
120
+ "Local expected tables, test-helper fixtures, enum exhaustiveness, names ending in `_REGISTRY`, and collections whose exact membership is asserted elsewhere in the module are excluded.",
121
+ "Project-wide relationships hidden behind helpers require judgment and remain unreported.",
122
+ ),
123
+ examples=(
124
+ RuleExample(
125
+ example_id="independent-model-cases",
126
+ title="Drive behavior from an independent expected table",
127
+ outcome=ExampleOutcome.NO_MATCH,
128
+ files=(
129
+ ExampleFile.python(
130
+ "tests/test_models.py",
131
+ "EXPECTED_MODELS = ('a', 'b')\n\n@pytest.mark.parametrize('model', EXPECTED_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
132
+ ),
133
+ ),
134
+ focus_path=PurePosixPath("tests/test_models.py"),
135
+ expected_count=0,
136
+ public=True,
137
+ ),
138
+ RuleExample(
139
+ example_id="production-derived-model-cases",
140
+ title="Do not derive cases from production eligibility",
141
+ outcome=ExampleOutcome.MATCH,
142
+ files=(
143
+ ExampleFile.python(
144
+ "tests/test_models.py",
145
+ "from app.models import ELIGIBLE_MODELS\n\n@pytest.mark.parametrize('model', ELIGIBLE_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
146
+ ),
147
+ ),
148
+ focus_path=PurePosixPath("tests/test_models.py"),
149
+ expected_count=1,
150
+ public=True,
151
+ ),
152
+ ),
153
+ )
154
+ description = documentation.summary
155
+
156
+ @override
157
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
158
+ if not is_test_path(path):
159
+ return []
160
+ tree = parse_or_none(path, source)
161
+ if tree is None:
162
+ return []
163
+ imported = _imported_bindings(tree)
164
+ independently_asserted = _independently_asserted_collections(tree, imported)
165
+ findings: list[Diagnostic] = []
166
+ for node in ast.walk(tree):
167
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or not node.name.startswith("test_"):
168
+ continue
169
+ for decorator in node.decorator_list:
170
+ cases = _parametrize_cases(decorator)
171
+ if cases is None:
172
+ continue
173
+ collection = _direct_imported_collection(cases, imported)
174
+ if collection is None or collection.id in independently_asserted:
175
+ continue
176
+ findings.append(
177
+ Diagnostic(
178
+ path=path,
179
+ line=decorator.lineno,
180
+ col=decorator.col_offset + 1,
181
+ code=self.code,
182
+ severity=Severity.WARNING,
183
+ message=(
184
+ f"`{collection.id}` supplies the cases from production, so removing a member can remove "
185
+ "the test that should catch the regression; define an independent expected case table and "
186
+ "assert the production collection equals it."
187
+ ),
188
+ )
189
+ )
190
+ findings.sort(key=lambda finding: (finding.line, finding.col))
191
+ return findings