sarj-python-lint 0.69.1__tar.gz → 0.70.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 (108) hide show
  1. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/duplicate_test_body.py +9 -0
  5. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +2 -2
  6. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +111 -12
  7. sarj_python_lint-0.70.0/src/sarj_python_lint/rules/repeated_static_call_cases.py +298 -0
  8. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +125 -11
  9. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/.gitignore +0 -0
  10. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/LICENSE +0 -0
  11. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/README.md +0 -0
  12. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/__init__.py +0 -0
  13. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/__main__.py +0 -0
  14. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_filesystem.py +0 -0
  15. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  16. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_secret_names.py +0 -0
  17. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/py.typed +0 -0
  19. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/ratchet.py +0 -0
  20. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  23. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  24. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  25. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  26. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  27. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  28. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  31. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  32. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  33. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  34. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  35. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  36. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  37. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  40. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  42. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  45. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  47. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  48. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  49. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  50. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  51. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  52. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  53. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  54. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  55. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  56. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  57. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  58. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  59. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  60. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  61. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  62. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  63. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  64. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  65. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  66. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  67. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  68. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  69. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  70. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  71. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  72. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  73. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  74. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  75. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  76. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  77. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  78. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  79. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  80. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  81. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  82. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  83. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  84. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  85. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  86. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  87. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  88. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  89. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  90. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  91. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  92. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  93. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  94. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  95. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  96. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  97. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  98. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  99. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  100. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  101. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  102. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  103. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  104. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  105. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  106. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  107. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  108. {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.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.69.1
3
+ Version: 0.70.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.69.1"
3
+ version = "0.70.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" }]
@@ -114,6 +114,7 @@ from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
114
114
  from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
115
115
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
116
116
  from sarj_python_lint.rules.redundant_module_docstring import RedundantModuleDocstring
117
+ from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCallCases
117
118
  from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
118
119
  RequireKeywordOnlySwapProneParams,
119
120
  )
@@ -218,6 +219,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
218
219
  NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
219
220
  ProductionDerivedTestCases.id: ProductionDerivedTestCases,
220
221
  UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
222
+ RepeatedStaticCallCases.id: RepeatedStaticCallCases,
221
223
  }
222
224
  )
223
225
 
@@ -382,6 +382,15 @@ def _duplicate_groups(tree: ast.Module, source: str) -> list[list[_Shape]]:
382
382
  return found
383
383
 
384
384
 
385
+ def duplicate_test_owner_ids(tree: ast.Module, source: str) -> frozenset[int]:
386
+ """Return the identities of tests participating in a SARJ066 group."""
387
+ try:
388
+ groups = _duplicate_groups(tree, source)
389
+ except tokenize.TokenError, IndentationError, SyntaxError, RecursionError:
390
+ return frozenset()
391
+ return frozenset(id(member.node) for group in groups for member in group)
392
+
393
+
385
394
  def _consecutive_embedded_source_groups(members: list[_Shape], positions: dict[int, int]) -> list[list[_Shape]]:
386
395
  """Keep only long uninterrupted runs of embedded-source checker tests."""
387
396
  ordered = sorted(members, key=lambda member: positions[id(member.node)])
@@ -21,7 +21,7 @@ from sarj_python_lint.rule_base import (
21
21
  Severity,
22
22
  parse_or_none,
23
23
  )
24
- from sarj_python_lint.rules._paths import is_test_path
24
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
25
25
 
26
26
 
27
27
  if TYPE_CHECKING:
@@ -138,7 +138,7 @@ class NegativeOnlyHttpStatusAssertion(Rule):
138
138
 
139
139
  @override
140
140
  def check(self, path: Path, source: str) -> list[Diagnostic]:
141
- if not is_test_path(path):
141
+ if not is_test_path(path) or is_generated(path, source):
142
142
  return []
143
143
  tree = parse_or_none(path, source)
144
144
  if tree is None:
@@ -21,7 +21,7 @@ from sarj_python_lint.rule_base import (
21
21
  Severity,
22
22
  parse_or_none,
23
23
  )
24
- from sarj_python_lint.rules._paths import is_test_path
24
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
25
25
 
26
26
 
27
27
  if TYPE_CHECKING:
@@ -33,7 +33,53 @@ _PARAMETRIZE_CASES_INDEX = 1
33
33
  _PARAMETRIZE_MIN_ARGS = 2
34
34
 
35
35
 
36
- def _imported_bindings(tree: ast.Module) -> set[str]:
36
+ def _scope_binding_counts(statements: list[ast.stmt]) -> dict[str, int]:
37
+ """Count bindings in one scope without attributing nested scopes to it."""
38
+ counts: dict[str, int] = {}
39
+ stack: list[ast.AST] = [*reversed(statements)]
40
+ while stack:
41
+ node = stack.pop()
42
+ names: set[str] = set()
43
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
44
+ names.add(node.name)
45
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
46
+ names.update(alias.asname or alias.name.split(".")[0] for alias in node.names)
47
+ elif isinstance(node, (ast.Assign, ast.Delete)):
48
+ names.update(*(_bound_target_names(target) for target in node.targets))
49
+ elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr, ast.For, ast.AsyncFor)):
50
+ names.update(_bound_target_names(node.target))
51
+ elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and node.name is not None:
52
+ names.add(node.name)
53
+ elif isinstance(node, ast.MatchMapping) and node.rest is not None:
54
+ names.add(node.rest)
55
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
56
+ names.update(
57
+ *(_bound_target_names(item.optional_vars) for item in node.items if item.optional_vars is not None)
58
+ )
59
+ for name in names:
60
+ counts[name] = counts.get(name, 0) + 1
61
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
62
+ continue
63
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
64
+ return counts
65
+
66
+
67
+ def _bound_target_names(node: ast.AST) -> set[str]:
68
+ match node:
69
+ case ast.Name(id=name):
70
+ return {name}
71
+ case ast.Tuple() | ast.List():
72
+ names: set[str] = set()
73
+ for element in node.elts:
74
+ names.update(_bound_target_names(element))
75
+ return names
76
+ case ast.Starred(value=value):
77
+ return _bound_target_names(value)
78
+ case _:
79
+ return set()
80
+
81
+
82
+ def _imported_bindings(tree: ast.Module, binding_counts: dict[str, int]) -> set[str]:
37
83
  bindings: set[str] = set()
38
84
  for node in tree.body:
39
85
  if (
@@ -41,10 +87,33 @@ def _imported_bindings(tree: ast.Module) -> set[str]:
41
87
  and node.module is not None
42
88
  and not node.module.startswith(("test", "tests"))
43
89
  ):
44
- bindings.update(alias.asname or alias.name for alias in node.names if alias.name != "*")
90
+ bindings.update(
91
+ local
92
+ for alias in node.names
93
+ if alias.name != "*" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
94
+ )
45
95
  return bindings
46
96
 
47
97
 
98
+ def _pytest_bindings(tree: ast.Module, binding_counts: dict[str, int]) -> tuple[set[str], set[str]]:
99
+ modules: set[str] = set()
100
+ marks: set[str] = set()
101
+ for node in tree.body:
102
+ if isinstance(node, ast.Import):
103
+ modules.update(
104
+ local
105
+ for alias in node.names
106
+ if alias.name == "pytest" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
107
+ )
108
+ elif isinstance(node, ast.ImportFrom) and node.module == "pytest":
109
+ marks.update(
110
+ local
111
+ for alias in node.names
112
+ if alias.name == "mark" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
113
+ )
114
+ return modules, marks
115
+
116
+
48
117
  def _direct_imported_collection(node: ast.expr, imported: set[str]) -> ast.Name | None:
49
118
  if isinstance(node, ast.Name) and node.id in imported and node.id.isupper() and not _is_registry(node.id):
50
119
  return node
@@ -79,15 +148,45 @@ def _is_imported_collection_wrapper(node: ast.Call, imported: set[str]) -> bool:
79
148
  )
80
149
 
81
150
 
82
- def _parametrize_cases(decorator: ast.expr) -> ast.expr | None:
151
+ def _parametrize_cases(
152
+ decorator: ast.expr,
153
+ pytest_modules: set[str],
154
+ pytest_marks: set[str],
155
+ blocked: set[str],
156
+ ) -> ast.expr | None:
83
157
  if not isinstance(decorator, ast.Call) or len(decorator.args) < _PARAMETRIZE_MIN_ARGS:
84
158
  return None
85
159
  func = decorator.func
86
160
  if not isinstance(func, ast.Attribute) or func.attr != "parametrize":
87
161
  return None
162
+ owner = func.value
163
+ module_owned = (
164
+ isinstance(owner, ast.Attribute)
165
+ and owner.attr == "mark"
166
+ and isinstance(owner.value, ast.Name)
167
+ and owner.value.id in pytest_modules - blocked
168
+ )
169
+ mark_owned = isinstance(owner, ast.Name) and owner.id in pytest_marks - blocked
170
+ if not module_owned and not mark_owned:
171
+ return None
88
172
  return decorator.args[_PARAMETRIZE_CASES_INDEX]
89
173
 
90
174
 
175
+ def _collected_tests(tree: ast.Module) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, set[str]]]:
176
+ tests: list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, set[str]]] = []
177
+ for node in tree.body:
178
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
179
+ tests.append((node, set()))
180
+ elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
181
+ class_bindings = set(_scope_binding_counts(node.body))
182
+ tests.extend(
183
+ (child, class_bindings)
184
+ for child in node.body
185
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
186
+ )
187
+ return tests
188
+
189
+
91
190
  def _independently_asserted_collections(tree: ast.Module, imported: set[str]) -> set[str]:
92
191
  asserted: set[str] = set()
93
192
  for node in ast.walk(tree):
@@ -128,7 +227,7 @@ class ProductionDerivedTestCases(Rule):
128
227
  files=(
129
228
  ExampleFile.python(
130
229
  "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",
230
+ "import pytest\n\nEXPECTED_MODELS = ('a', 'b')\n\n@pytest.mark.parametrize('model', EXPECTED_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
132
231
  ),
133
232
  ),
134
233
  focus_path=PurePosixPath("tests/test_models.py"),
@@ -142,7 +241,7 @@ class ProductionDerivedTestCases(Rule):
142
241
  files=(
143
242
  ExampleFile.python(
144
243
  "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",
244
+ "import pytest\nfrom 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
245
  ),
147
246
  ),
148
247
  focus_path=PurePosixPath("tests/test_models.py"),
@@ -155,19 +254,19 @@ class ProductionDerivedTestCases(Rule):
155
254
 
156
255
  @override
157
256
  def check(self, path: Path, source: str) -> list[Diagnostic]:
158
- if not is_test_path(path):
257
+ if not is_test_path(path) or is_generated(path, source):
159
258
  return []
160
259
  tree = parse_or_none(path, source)
161
260
  if tree is None:
162
261
  return []
163
- imported = _imported_bindings(tree)
262
+ binding_counts = _scope_binding_counts(tree.body)
263
+ imported = _imported_bindings(tree, binding_counts)
264
+ pytest_modules, pytest_marks = _pytest_bindings(tree, binding_counts)
164
265
  independently_asserted = _independently_asserted_collections(tree, imported)
165
266
  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
267
+ for node, blocked in _collected_tests(tree):
169
268
  for decorator in node.decorator_list:
170
- cases = _parametrize_cases(decorator)
269
+ cases = _parametrize_cases(decorator, pytest_modules, pytest_marks, blocked)
171
270
  if cases is None:
172
271
  continue
173
272
  collection = _direct_imported_collection(cases, imported)
@@ -0,0 +1,298 @@
1
+ """SARJ413 — Repeated static call assertions should be named test cases.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_repeated_static_call_cases.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from io import StringIO
10
+ from pathlib import PurePosixPath
11
+ import tokenize
12
+ from typing import TYPE_CHECKING, final, override
13
+
14
+ from sarj_python_lint.rule_base import (
15
+ AutofixPolicy,
16
+ Diagnostic,
17
+ ExampleFile,
18
+ ExampleOutcome,
19
+ Rule,
20
+ RuleCategory,
21
+ RuleDocumentation,
22
+ RuleExample,
23
+ Severity,
24
+ parse_or_none,
25
+ )
26
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
27
+ from sarj_python_lint.rules.duplicate_test_body import duplicate_test_owner_ids
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Iterator
32
+ from pathlib import Path
33
+
34
+
35
+ _MIN_CASES = 3
36
+ _MIN_DISTINCT_CASES = 2
37
+ _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
38
+ _ACCESSOR_CALLEES = frozenset({"get"})
39
+ _UNSAFE_CALLEE_PARTS = frozenset({"mock", "snapshot", "spy"})
40
+
41
+
42
+ @final
43
+ class RepeatedStaticCallCases(Rule):
44
+ id = "repeated-static-call-cases"
45
+ code = "SARJ413"
46
+ documentation = RuleDocumentation(
47
+ summary="Repeated static call assertions are hidden inside one coarse test case.",
48
+ rationale=(
49
+ "When several independent static inputs share one test callback, the first failure hides later cases "
50
+ "and the runner cannot name the input that failed."
51
+ ),
52
+ remediation="Move the inputs and expectations into a named pytest parameter table.",
53
+ category=RuleCategory.TESTING,
54
+ autofix=AutofixPolicy.NONE,
55
+ limitations=(
56
+ "Only runs of at least three consecutive top-level assertions in collected pytest-style tests are checked.",
57
+ "Calls, inputs, and expectations must be statically representable; unittest classes, zero-argument calls, mocks, snapshots, and intervening prose or setup are excluded.",
58
+ "Common mapping accessors are excluded because repeated field assertions usually describe one cohesive object contract, not independent input cases.",
59
+ "Tests participating in a duplicate-test-body group are left to SARJ066, which has the broader finding.",
60
+ "Case names and parameter boundaries require judgment, so the rule has no autofix.",
61
+ ),
62
+ examples=(
63
+ RuleExample(
64
+ example_id="parameterized-parser-cases",
65
+ title="Give each parser input a runner-visible case",
66
+ outcome=ExampleOutcome.NO_MATCH,
67
+ files=(
68
+ ExampleFile.python(
69
+ "tests/test_parser.py",
70
+ "@pytest.mark.parametrize(('value', 'expected'), [('a', 1), ('b', 2), ('c', 3)])\ndef test_parse(value, expected):\n assert parse(value) == expected\n",
71
+ ),
72
+ ),
73
+ focus_path=PurePosixPath("tests/test_parser.py"),
74
+ expected_count=0,
75
+ public=True,
76
+ ),
77
+ RuleExample(
78
+ example_id="repeated-parser-assertions",
79
+ title="Do not hide independent inputs in one callback",
80
+ outcome=ExampleOutcome.MATCH,
81
+ files=(
82
+ ExampleFile.python(
83
+ "tests/test_parser.py",
84
+ "def test_parse():\n assert parse('a') == 1\n assert parse('b') == 2\n assert parse('c') == 3\n",
85
+ ),
86
+ ),
87
+ focus_path=PurePosixPath("tests/test_parser.py"),
88
+ expected_count=1,
89
+ public=True,
90
+ ),
91
+ ),
92
+ )
93
+ description = documentation.summary
94
+
95
+ @override
96
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
97
+ if not is_test_path(path) or is_generated(path, source):
98
+ return []
99
+ tree = parse_or_none(path, source)
100
+ if tree is None:
101
+ return []
102
+ comments = _comment_lines(source)
103
+ duplicate_owners = duplicate_test_owner_ids(tree, source)
104
+ findings = [
105
+ Diagnostic(
106
+ path=path,
107
+ line=run[0].lineno,
108
+ col=run[0].col_offset + 1,
109
+ code=self.code,
110
+ severity=Severity.WARNING,
111
+ message=(
112
+ f"these {len(run)} static call assertions run as one coarse test; move the inputs and "
113
+ "expectations into named pytest parameters."
114
+ ),
115
+ )
116
+ for test in _test_functions(tree)
117
+ if id(test) not in duplicate_owners
118
+ for run in _runs(test, comments)
119
+ ]
120
+ return sorted(findings, key=lambda finding: (finding.line, finding.col))
121
+
122
+
123
+ def _dotted_name(node: ast.expr) -> tuple[str, ...] | None:
124
+ if isinstance(node, ast.Name):
125
+ return (node.id,)
126
+ if isinstance(node, ast.Attribute):
127
+ parent = _dotted_name(node.value)
128
+ return None if parent is None else (*parent, node.attr)
129
+ return None
130
+
131
+
132
+ def _comment_lines(source: str) -> frozenset[int]:
133
+ try:
134
+ return frozenset(
135
+ token.start[0]
136
+ for token in tokenize.generate_tokens(StringIO(source).readline)
137
+ if token.type == tokenize.COMMENT
138
+ )
139
+ except IndentationError, tokenize.TokenError:
140
+ return frozenset()
141
+
142
+
143
+ def _test_functions(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
144
+ local_classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)}
145
+
146
+ def is_test_case(node: ast.ClassDef, seen: frozenset[str] = frozenset()) -> bool:
147
+ if node.name in seen:
148
+ return False
149
+ for base in node.bases:
150
+ name = _base_name(base)
151
+ if name is not None and name.endswith("TestCase"):
152
+ return True
153
+ if name in local_classes and is_test_case(local_classes[name], seen | {node.name}):
154
+ return True
155
+ return False
156
+
157
+ for statement in tree.body:
158
+ if isinstance(statement, _FUNC_NODES) and statement.name.startswith("test_"):
159
+ yield statement
160
+ elif isinstance(statement, ast.ClassDef) and not is_test_case(statement):
161
+ yield from (
162
+ child for child in statement.body if isinstance(child, _FUNC_NODES) and child.name.startswith("test_")
163
+ )
164
+
165
+
166
+ def _base_name(node: ast.expr) -> str | None:
167
+ dotted = _dotted_name(node)
168
+ return None if dotted is None else dotted[-1]
169
+
170
+
171
+ def _runs(test: ast.FunctionDef | ast.AsyncFunctionDef, comments: frozenset[int]) -> Iterator[list[ast.Assert]]:
172
+ current: list[ast.Assert] = []
173
+ current_shape: object | None = None
174
+ current_values: set[str] = set()
175
+ for statement in test.body:
176
+ if not isinstance(statement, ast.Assert) or _has_attached_comment(statement, comments):
177
+ if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
178
+ yield current
179
+ current, current_shape, current_values = [], None, set()
180
+ continue
181
+ parsed = _assertion_shape(statement)
182
+ if parsed is None:
183
+ if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
184
+ yield current
185
+ current, current_shape, current_values = [], None, set()
186
+ continue
187
+ shape, values = parsed
188
+ if current and (shape != current_shape or _has_intervening_comment(current[-1], statement, comments)):
189
+ if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
190
+ yield current
191
+ current, current_values = [], set()
192
+ current.append(statement)
193
+ current_shape = shape
194
+ current_values.add(values)
195
+ if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
196
+ yield current
197
+
198
+
199
+ def _assertion_shape(node: ast.Assert) -> tuple[object, str] | None:
200
+ expression = node.test
201
+ polarity = "truthy"
202
+ expectation: ast.expr | None = None
203
+ call_expr: ast.expr
204
+ if isinstance(expression, ast.UnaryOp) and isinstance(expression.op, ast.Not):
205
+ polarity = "falsy"
206
+ call_expr = expression.operand
207
+ elif isinstance(expression, ast.Compare) and len(expression.ops) == 1 and len(expression.comparators) == 1:
208
+ call_expr = expression.left
209
+ expectation = expression.comparators[0]
210
+ polarity = type(expression.ops[0]).__name__
211
+ else:
212
+ call_expr = expression
213
+ parsed = _call(call_expr)
214
+ if parsed is None or (expectation is not None and not _static(expectation)):
215
+ return None
216
+ call, awaited = parsed
217
+ callee = _dotted_name(call.func)
218
+ if (
219
+ callee is None
220
+ or not _eligible_callee(callee)
221
+ or not call.args
222
+ or any(isinstance(arg, ast.Starred) or not _static(arg) for arg in call.args)
223
+ or any(keyword.arg is None or not _static(keyword.value) for keyword in call.keywords)
224
+ ):
225
+ return None
226
+ skeleton = (
227
+ callee,
228
+ awaited,
229
+ polarity,
230
+ tuple(_static_shape(arg) for arg in call.args),
231
+ tuple((keyword.arg, _static_shape(keyword.value)) for keyword in call.keywords),
232
+ None if expectation is None else _static_shape(expectation),
233
+ )
234
+ values = _static_value(ast.Tuple(elts=[*call.args, *(keyword.value for keyword in call.keywords)], ctx=ast.Load()))
235
+ return skeleton, values
236
+
237
+
238
+ def _eligible_callee(callee: tuple[str, ...]) -> bool:
239
+ return callee[-1] not in _ACCESSOR_CALLEES and not any(
240
+ any(part in segment.lower() for part in _UNSAFE_CALLEE_PARTS) for segment in callee
241
+ )
242
+
243
+
244
+ def _has_intervening_comment(previous: ast.Assert, current: ast.Assert, comments: frozenset[int]) -> bool:
245
+ return any((previous.end_lineno or previous.lineno) < line < current.lineno for line in comments)
246
+
247
+
248
+ def _has_attached_comment(statement: ast.Assert, comments: frozenset[int]) -> bool:
249
+ return any(statement.lineno <= line <= (statement.end_lineno or statement.lineno) for line in comments)
250
+
251
+
252
+ def _call(node: ast.expr) -> tuple[ast.Call, bool] | None:
253
+ awaited = isinstance(node, ast.Await)
254
+ candidate = node.value if awaited else node
255
+ return (candidate, awaited) if isinstance(candidate, ast.Call) else None
256
+
257
+
258
+ def _static(node: ast.expr) -> bool:
259
+ match node:
260
+ case ast.Constant():
261
+ return True
262
+ case ast.Attribute():
263
+ return _dotted_name(node) is not None
264
+ case ast.UnaryOp(op=ast.UAdd() | ast.USub() | ast.Invert(), operand=operand):
265
+ return _static(operand)
266
+ case ast.Tuple() | ast.List() | ast.Set():
267
+ return all(_static(elt) for elt in node.elts)
268
+ case ast.Dict(keys=keys, values=values):
269
+ return all(key is not None and _static(key) for key in keys) and all(_static(value) for value in values)
270
+ case _:
271
+ return False
272
+
273
+
274
+ def _static_shape(node: ast.expr) -> object:
275
+ match node:
276
+ case ast.Constant(value=value):
277
+ return ("constant", type(value).__name__)
278
+ case ast.Attribute():
279
+ return ("symbol", len(_dotted_name(node) or ()))
280
+ case ast.UnaryOp(op=op, operand=operand):
281
+ return (type(op).__name__, _static_shape(operand))
282
+ case ast.Tuple() | ast.List() | ast.Set():
283
+ return (type(node).__name__, tuple(_static_shape(elt) for elt in node.elts))
284
+ case ast.Dict(keys=keys, values=values):
285
+ return (
286
+ "Dict",
287
+ tuple(
288
+ (_static_shape(key), _static_shape(value)) for key, value in zip(keys, values, strict=True) if key
289
+ ),
290
+ )
291
+ case _:
292
+ raise AssertionError
293
+
294
+
295
+ def _static_value(node: ast.expr) -> str:
296
+ """Return a bounded comparison token without retaining a large literal."""
297
+ rendered = ast.dump(node, annotate_fields=False, include_attributes=False)
298
+ return rendered[:512]
@@ -21,7 +21,7 @@ from sarj_python_lint.rule_base import (
21
21
  Severity,
22
22
  parse_or_none,
23
23
  )
24
- from sarj_python_lint.rules._paths import is_test_path
24
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
25
25
 
26
26
 
27
27
  if TYPE_CHECKING:
@@ -33,16 +33,78 @@ _PRNG_FUNCTIONS = frozenset({"choice", "choices", "randint", "random", "randrang
33
33
  _REPEAT_NODES = (ast.For, ast.AsyncFor, ast.While, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
34
34
 
35
35
 
36
+ def _bound_target_names(node: ast.AST) -> set[str]:
37
+ match node:
38
+ case ast.Name(id=name):
39
+ return {name}
40
+ case ast.Tuple() | ast.List():
41
+ names: set[str] = set()
42
+ for element in node.elts:
43
+ names.update(_bound_target_names(element))
44
+ return names
45
+ case ast.Starred(value=value):
46
+ return _bound_target_names(value)
47
+ case _:
48
+ return set()
49
+
50
+
51
+ def _scope_binding_events(statements: list[ast.stmt]) -> list[str]:
52
+ """Return binding events in one scope without entering nested scopes."""
53
+ bindings: list[str] = []
54
+ stack: list[ast.AST] = [*reversed(statements)]
55
+ while stack:
56
+ node = stack.pop()
57
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
58
+ bindings.append(node.name)
59
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
60
+ bindings.extend(alias.asname or alias.name.split(".")[0] for alias in node.names)
61
+ elif isinstance(node, (ast.Assign, ast.Delete)):
62
+ for target in node.targets:
63
+ bindings.extend(_bound_target_names(target))
64
+ elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr, ast.For, ast.AsyncFor)):
65
+ bindings.extend(_bound_target_names(node.target))
66
+ elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and node.name is not None:
67
+ bindings.append(node.name)
68
+ elif isinstance(node, ast.MatchMapping) and node.rest is not None:
69
+ bindings.append(node.rest)
70
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
71
+ for item in node.items:
72
+ if item.optional_vars is not None:
73
+ bindings.extend(_bound_target_names(item.optional_vars))
74
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
75
+ continue
76
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
77
+ return bindings
78
+
79
+
80
+ def _scope_bindings(statements: list[ast.stmt]) -> set[str]:
81
+ return set(_scope_binding_events(statements))
82
+
83
+
84
+ def _module_binding_counts(tree: ast.Module) -> dict[str, int]:
85
+ counts: dict[str, int] = {}
86
+ for name in _scope_binding_events(tree.body):
87
+ counts[name] = counts.get(name, 0) + 1
88
+ return counts
89
+
90
+
36
91
  def _random_aliases(tree: ast.Module) -> tuple[set[str], set[str], set[str]]:
37
92
  modules: set[str] = set()
38
93
  functions: set[str] = set()
39
94
  seeds: set[str] = set()
95
+ binding_counts = _module_binding_counts(tree)
40
96
  for node in tree.body:
41
97
  if isinstance(node, ast.Import):
42
- modules.update(alias.asname or alias.name for alias in node.names if alias.name == "random")
98
+ modules.update(
99
+ local
100
+ for alias in node.names
101
+ if alias.name == "random" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
102
+ )
43
103
  elif isinstance(node, ast.ImportFrom) and node.module == "random":
44
104
  for alias in node.names:
45
105
  local = alias.asname or alias.name
106
+ if binding_counts.get(local, 0) != 1:
107
+ continue
46
108
  if alias.name in _PRNG_FUNCTIONS:
47
109
  functions.add(local)
48
110
  elif alias.name == "seed":
@@ -78,11 +140,38 @@ def _test_nodes(test: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AS
78
140
  while stack:
79
141
  node = stack.pop()
80
142
  yield node
81
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
143
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
82
144
  continue
83
145
  stack.extend(reversed(list(ast.iter_child_nodes(node))))
84
146
 
85
147
 
148
+ def _bounded_nodes(root: ast.AST) -> Iterator[ast.AST]:
149
+ """Walk a subtree without entering nested callable or class scopes."""
150
+ stack = [root]
151
+ while stack:
152
+ node = stack.pop()
153
+ yield node
154
+ if node is not root and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
155
+ continue
156
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
157
+
158
+
159
+ def _test_nodes_for_statement(statement: ast.stmt) -> Iterator[ast.AST]:
160
+ yield from _bounded_nodes(statement)
161
+
162
+
163
+ def _collected_tests(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
164
+ for node in tree.body:
165
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
166
+ yield node
167
+ elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
168
+ yield from (
169
+ child
170
+ for child in node.body
171
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
172
+ )
173
+
174
+
86
175
  @final
87
176
  class UncontrolledRandomnessInTest(Rule):
88
177
  id = "uncontrolled-randomness-in-test"
@@ -99,7 +188,7 @@ class UncontrolledRandomnessInTest(Rule):
99
188
  limitations=(
100
189
  "Only standard-library `random` calls nested in loops or comprehensions in collected tests are checked.",
101
190
  "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.",
191
+ "Only an unconditional top-level seed before the repeated sample suppresses the finding; interprocedural seeding is not inferred.",
103
192
  ),
104
193
  examples=(
105
194
  RuleExample(
@@ -136,7 +225,7 @@ class UncontrolledRandomnessInTest(Rule):
136
225
 
137
226
  @override
138
227
  def check(self, path: Path, source: str) -> list[Diagnostic]:
139
- if not is_test_path(path):
228
+ if not is_test_path(path) or is_generated(path, source):
140
229
  return []
141
230
  tree = parse_or_none(path, source)
142
231
  if tree is None:
@@ -145,17 +234,42 @@ class UncontrolledRandomnessInTest(Rule):
145
234
  if not modules and not functions:
146
235
  return []
147
236
  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_"):
237
+ for test in _collected_tests(tree):
238
+ local_bindings = _scope_bindings(test.body) | {
239
+ arg.arg for arg in (*test.args.posonlyargs, *test.args.args, *test.args.kwonlyargs)
240
+ }
241
+ if test.args.vararg is not None:
242
+ local_bindings.add(test.args.vararg.arg)
243
+ if test.args.kwarg is not None:
244
+ local_bindings.add(test.args.kwarg.arg)
245
+ test_modules = modules - local_bindings
246
+ test_functions = functions - local_bindings
247
+ test_seeds = seeds - local_bindings
248
+ if not test_modules and not test_functions:
150
249
  continue
151
250
  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
251
+ top_level_seed_indexes = {
252
+ index
253
+ for index, statement in enumerate(test.body)
254
+ if isinstance(statement, ast.Expr)
255
+ and isinstance(statement.value, ast.Call)
256
+ and _is_seed_call(statement.value, test_modules, test_seeds)
257
+ }
154
258
  for repeated in (node for node in nodes if isinstance(node, _REPEAT_NODES)):
259
+ owner_index = next(
260
+ (
261
+ index
262
+ for index, statement in enumerate(test.body)
263
+ if repeated is statement or repeated in _test_nodes_for_statement(statement)
264
+ ),
265
+ None,
266
+ )
267
+ if owner_index is not None and any(seed_index < owner_index for seed_index in top_level_seed_indexes):
268
+ continue
155
269
  calls = [
156
270
  node
157
- for node in ast.walk(repeated)
158
- if isinstance(node, ast.Call) and _is_prng_call(node, modules, functions)
271
+ for node in _bounded_nodes(repeated)
272
+ if isinstance(node, ast.Call) and _is_prng_call(node, test_modules, test_functions)
159
273
  ]
160
274
  findings.extend(
161
275
  Diagnostic(