sarj-python-lint 0.67.12__tar.gz → 0.67.14__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.12 → sarj_python_lint-0.67.14}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_registry.py +8 -4
  4. sarj_python_lint-0.67.14/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +169 -0
  5. sarj_python_lint-0.67.14/src/sarj_python_lint/rules/production_derived_test_cases.py +191 -0
  6. sarj_python_lint-0.67.14/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +176 -0
  7. sarj_python_lint-0.67.12/src/sarj_python_lint/rules/no_apirouter_root_trailing_slash.py +0 -120
  8. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/.gitignore +0 -0
  9. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/LICENSE +0 -0
  10. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/README.md +0 -0
  11. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_paths.py +0 -0
  29. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  35. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  36. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  37. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  38. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  39. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  40. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  42. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  43. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  44. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  46. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  47. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  48. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  49. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  50. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  51. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  52. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  53. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  54. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  55. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  56. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  57. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  58. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  60. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  61. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  62. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  63. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  64. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  65. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  66. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  67. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  68. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  69. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  70. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  71. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  72. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  73. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  74. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  75. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  76. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  77. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  78. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  79. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  80. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  81. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  82. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  83. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  84. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  85. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  86. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  87. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  88. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  89. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  90. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  91. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  92. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  93. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  94. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  95. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  96. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  97. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  98. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  99. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  100. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  101. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  102. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/stepdown.py +0 -0
  103. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  104. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  105. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  106. {sarj_python_lint-0.67.12 → sarj_python_lint-0.67.14}/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.12
3
+ Version: 0.67.14
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.12"
3
+ version = "0.67.14"
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,12 +24,12 @@ 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
  )
30
- from sarj_python_lint.rules.no_apirouter_root_trailing_slash import (
31
- NoApirouterRootTrailingSlash,
32
- )
33
33
  from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
34
34
  from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
35
35
  NoCorsWildcardWithCredentials,
@@ -108,6 +108,7 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
108
108
  )
109
109
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
110
110
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
111
+ from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
111
112
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
112
113
  from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
113
114
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
@@ -124,6 +125,7 @@ from sarj_python_lint.rules.store_insert_requires_on_conflict import (
124
125
  )
125
126
  from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarration
126
127
  from sarj_python_lint.rules.trivially_true_assertion import TriviallyTrueAssertion
128
+ from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
127
129
  from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
128
130
 
129
131
 
@@ -154,7 +156,6 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
154
156
  SourceCoupledTest.id: SourceCoupledTest,
155
157
  NoQueryWithManyJoins.id: NoQueryWithManyJoins,
156
158
  NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
157
- NoApirouterRootTrailingSlash.id: NoApirouterRootTrailingSlash,
158
159
  NoSelectStar.id: NoSelectStar,
159
160
  NoGenericSingleExportModule.id: NoGenericSingleExportModule,
160
161
  Stepdown.id: Stepdown,
@@ -208,6 +209,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
208
209
  NoTypedDocSections.id: NoTypedDocSections,
209
210
  PreferNominalIdTypes.id: PreferNominalIdTypes,
210
211
  NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
212
+ NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
213
+ ProductionDerivedTestCases.id: ProductionDerivedTestCases,
214
+ UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
211
215
  }
212
216
  )
213
217
 
@@ -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
@@ -0,0 +1,176 @@
1
+ """SARJ410 — Repeated unseeded PRNG sampling makes a test probabilistic.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_uncontrolled_randomness_in_test.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
+ _PRNG_FUNCTIONS = frozenset({"choice", "choices", "randint", "random", "randrange", "sample", "shuffle", "uniform"})
33
+ _REPEAT_NODES = (ast.For, ast.AsyncFor, ast.While, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
34
+
35
+
36
+ def _random_aliases(tree: ast.Module) -> tuple[set[str], set[str], set[str]]:
37
+ modules: set[str] = set()
38
+ functions: set[str] = set()
39
+ seeds: set[str] = set()
40
+ for node in tree.body:
41
+ if isinstance(node, ast.Import):
42
+ modules.update(alias.asname or alias.name for alias in node.names if alias.name == "random")
43
+ elif isinstance(node, ast.ImportFrom) and node.module == "random":
44
+ for alias in node.names:
45
+ local = alias.asname or alias.name
46
+ if alias.name in _PRNG_FUNCTIONS:
47
+ functions.add(local)
48
+ elif alias.name == "seed":
49
+ seeds.add(local)
50
+ return modules, functions, seeds
51
+
52
+
53
+ def _is_prng_call(node: ast.Call, modules: set[str], functions: set[str]) -> bool:
54
+ if isinstance(node.func, ast.Name):
55
+ return node.func.id in functions
56
+ return (
57
+ isinstance(node.func, ast.Attribute)
58
+ and node.func.attr in _PRNG_FUNCTIONS
59
+ and isinstance(node.func.value, ast.Name)
60
+ and node.func.value.id in modules
61
+ )
62
+
63
+
64
+ def _is_seed_call(node: ast.Call, modules: set[str], seeds: set[str]) -> bool:
65
+ if isinstance(node.func, ast.Name):
66
+ return node.func.id in seeds
67
+ return (
68
+ isinstance(node.func, ast.Attribute)
69
+ and node.func.attr == "seed"
70
+ and isinstance(node.func.value, ast.Name)
71
+ and node.func.value.id in modules
72
+ )
73
+
74
+
75
+ def _test_nodes(test: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
76
+ """Walk one collected test without attributing nested helpers to it."""
77
+ stack: list[ast.AST] = [*reversed(test.body)]
78
+ while stack:
79
+ node = stack.pop()
80
+ yield node
81
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
82
+ continue
83
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
84
+
85
+
86
+ @final
87
+ class UncontrolledRandomnessInTest(Rule):
88
+ id = "uncontrolled-randomness-in-test"
89
+ code = "SARJ410"
90
+ documentation = RuleDocumentation(
91
+ summary="Test repeatedly samples the standard PRNG without a seed or injected deterministic random source.",
92
+ rationale=(
93
+ "Repeated unseeded sampling makes pass/fail outcomes vary across identical runs and broad frequency "
94
+ "bounds can conceal heavily biased behavior."
95
+ ),
96
+ remediation="Inject a deterministic RNG, seed it explicitly, or use a property framework that records failing seeds.",
97
+ category=RuleCategory.TESTING,
98
+ autofix=AutofixPolicy.NONE,
99
+ limitations=(
100
+ "Only standard-library `random` calls nested in loops or comprehensions in collected tests are checked.",
101
+ "Single draws, injected RNG objects, Hypothesis tests, and cryptographic randomness are excluded.",
102
+ "A seed anywhere in the test suppresses the finding; interprocedural seeding is not inferred.",
103
+ ),
104
+ examples=(
105
+ RuleExample(
106
+ example_id="seeded-random-sampling",
107
+ title="Make repeated random sampling reproducible",
108
+ outcome=ExampleOutcome.NO_MATCH,
109
+ files=(
110
+ ExampleFile.python(
111
+ "tests/test_selector.py",
112
+ "import random\n\ndef test_distribution():\n random.seed(17)\n values = [random.random() for _ in range(50)]\n assert values\n",
113
+ ),
114
+ ),
115
+ focus_path=PurePosixPath("tests/test_selector.py"),
116
+ expected_count=0,
117
+ public=True,
118
+ ),
119
+ RuleExample(
120
+ example_id="unseeded-random-sampling",
121
+ title="Do not repeat probabilistic trials without reproducibility",
122
+ outcome=ExampleOutcome.MATCH,
123
+ files=(
124
+ ExampleFile.python(
125
+ "tests/test_selector.py",
126
+ "import random\n\ndef test_distribution():\n values = [random.random() for _ in range(50)]\n assert values\n",
127
+ ),
128
+ ),
129
+ focus_path=PurePosixPath("tests/test_selector.py"),
130
+ expected_count=1,
131
+ public=True,
132
+ ),
133
+ ),
134
+ )
135
+ description = documentation.summary
136
+
137
+ @override
138
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
139
+ if not is_test_path(path):
140
+ return []
141
+ tree = parse_or_none(path, source)
142
+ if tree is None:
143
+ return []
144
+ modules, functions, seeds = _random_aliases(tree)
145
+ if not modules and not functions:
146
+ return []
147
+ findings: list[Diagnostic] = []
148
+ for test in ast.walk(tree):
149
+ if not isinstance(test, (ast.FunctionDef, ast.AsyncFunctionDef)) or not test.name.startswith("test_"):
150
+ continue
151
+ nodes = list(_test_nodes(test))
152
+ if any(isinstance(node, ast.Call) and _is_seed_call(node, modules, seeds) for node in nodes):
153
+ continue
154
+ for repeated in (node for node in nodes if isinstance(node, _REPEAT_NODES)):
155
+ calls = [
156
+ node
157
+ for node in ast.walk(repeated)
158
+ if isinstance(node, ast.Call) and _is_prng_call(node, modules, functions)
159
+ ]
160
+ findings.extend(
161
+ Diagnostic(
162
+ path=path,
163
+ line=call.lineno,
164
+ col=call.col_offset + 1,
165
+ code=self.code,
166
+ severity=Severity.WARNING,
167
+ message=(
168
+ "this test repeatedly samples an unseeded PRNG, so the same code can pass or fail across "
169
+ "runs; inject a deterministic RNG, set an explicit seed, or use a property-testing "
170
+ "framework with recorded seeds."
171
+ ),
172
+ )
173
+ for call in calls
174
+ )
175
+ unique = {(finding.line, finding.col): finding for finding in findings}
176
+ return [unique[key] for key in sorted(unique)]
@@ -1,120 +0,0 @@
1
- """SARJ405 — APIRouter collection roots use an empty path, not ``/``.
2
-
3
- Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_apirouter_root_trailing_slash.py
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import ast
9
- from pathlib import PurePosixPath
10
- from typing import TYPE_CHECKING, ClassVar, 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._ast_index import nodes
25
- from sarj_python_lint.rules._fastapi import FastapiIndex
26
- from sarj_python_lint.rules._paths import is_generated, is_test_path
27
-
28
-
29
- if TYPE_CHECKING:
30
- from pathlib import Path
31
-
32
-
33
- _DOCUMENTATION_EXAMPLE_DIR_NAMES = frozenset({"docs_src"})
34
-
35
-
36
- @final
37
- class NoApirouterRootTrailingSlash(Rule):
38
- id: str = "no-apirouter-root-trailing-slash"
39
- code: str = "SARJ405"
40
- documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
41
- summary="APIRouter collection-root operations should use an empty path instead of `/`.",
42
- rationale=(
43
- "A `/` path beneath an APIRouter prefix makes the prefix without its trailing slash redirect, "
44
- "rather than serving the canonical collection URL directly."
45
- ),
46
- remediation="Declare the APIRouter operation path as an empty string so the prefix itself is canonical.",
47
- category=RuleCategory.CORRECTNESS,
48
- autofix=AutofixPolicy.NONE,
49
- limitations=(
50
- "Only FastAPI and APIRouter bindings whose constructor provenance is visible in one module are resolved.",
51
- "Dynamic route paths are ignored because their runtime value cannot be proven.",
52
- "Tests, generated sources, and FastAPI documentation-source examples are excluded.",
53
- ),
54
- examples=(
55
- RuleExample(
56
- example_id="router-root-trailing-slash",
57
- title="APIRouter root operation adds a trailing slash",
58
- outcome=ExampleOutcome.MATCH,
59
- files=(
60
- ExampleFile.python(
61
- "api.py",
62
- "from fastapi import APIRouter\n\nrouter = APIRouter(prefix='/items')\n\n"
63
- "@router.get('/')\nasync def list_items() -> list[str]:\n return []\n",
64
- ),
65
- ),
66
- focus_path=PurePosixPath("api.py"),
67
- expected_count=1,
68
- public=True,
69
- ),
70
- RuleExample(
71
- example_id="canonical-router-root",
72
- title="APIRouter root operation uses its prefix directly",
73
- outcome=ExampleOutcome.NO_MATCH,
74
- files=(
75
- ExampleFile.python(
76
- "api.py",
77
- "from fastapi import APIRouter\n\nrouter = APIRouter(prefix='/items')\n\n"
78
- "@router.get('')\nasync def list_items() -> list[str]:\n return []\n",
79
- ),
80
- ),
81
- focus_path=PurePosixPath("api.py"),
82
- expected_count=0,
83
- public=True,
84
- ),
85
- ),
86
- )
87
- description: str = documentation.summary
88
-
89
- @override
90
- def check(self, path: Path, source: str) -> list[Diagnostic]:
91
- if (
92
- is_test_path(path)
93
- or is_generated(path, source)
94
- or any(part.lower() in _DOCUMENTATION_EXAMPLE_DIR_NAMES for part in path.parts)
95
- ):
96
- return []
97
- tree = parse_or_none(path, source)
98
- if tree is None:
99
- return []
100
- index = FastapiIndex(tree, path=path)
101
- decorators: dict[int, ast.Call] = {}
102
- for function in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef):
103
- for route in index.routes(function):
104
- if route.receiver_kind == "APIRouter" and route.path == "/":
105
- decorators[id(route.decorator)] = route.decorator
106
-
107
- return [
108
- Diagnostic(
109
- path=path,
110
- line=decorator.lineno,
111
- col=decorator.col_offset + 1,
112
- code=self.code,
113
- severity=Severity.WARNING,
114
- message=(
115
- "APIRouter collection-root path is `/`, so the prefix without a slash redirects — "
116
- "declare the path as an empty string. Suppress with `# sarj-noqa: SARJ405`."
117
- ),
118
- )
119
- for decorator in sorted(decorators.values(), key=lambda node: (node.lineno, node.col_offset))
120
- ]