sarj-python-lint 0.68.0__tar.gz → 0.69.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 (107) hide show
  1. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. sarj_python_lint-0.69.0/src/sarj_python_lint/rules/iac_source_coupled_test.py +107 -0
  5. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/source_coupled_test.py +193 -53
  6. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/.gitignore +0 -0
  7. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/LICENSE +0 -0
  8. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/README.md +0 -0
  9. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  23. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  25. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  26. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  27. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  28. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  29. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  30. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  31. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  32. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  33. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  34. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  35. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  36. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  37. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  38. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  39. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  40. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  41. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  42. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  43. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  44. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  45. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  46. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  47. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  48. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  49. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  50. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  51. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  52. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  53. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  54. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  55. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  56. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  57. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  58. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  59. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  60. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  61. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  62. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  63. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  64. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  65. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  66. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  67. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  68. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  69. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  70. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  71. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  72. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  73. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  74. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  75. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  76. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  77. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  78. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  79. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  80. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  81. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  82. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  83. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  84. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  85. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  86. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  87. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  88. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  89. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  90. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  91. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  92. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  93. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  94. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  95. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  96. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  97. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  98. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  99. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  100. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  101. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  102. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  103. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  104. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  105. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  106. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  107. {sarj_python_lint-0.68.0 → sarj_python_lint-0.69.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.68.0
3
+ Version: 0.69.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.68.0"
3
+ version = "0.69.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" }]
@@ -19,6 +19,7 @@ from sarj_python_lint.rules.duplicated_override_docstring import (
19
19
  )
20
20
  from sarj_python_lint.rules.fastapi_openapi_contract import FastapiOpenapiContract
21
21
  from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
22
+ from sarj_python_lint.rules.iac_source_coupled_test import IacSourceCoupledTest
22
23
  from sarj_python_lint.rules.invalid_pydantic_field_default import (
23
24
  InvalidPydanticFieldDefault,
24
25
  )
@@ -157,6 +158,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
157
158
  NoCommentCruft.id: NoCommentCruft,
158
159
  StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
159
160
  SourceCoupledTest.id: SourceCoupledTest,
161
+ IacSourceCoupledTest.id: IacSourceCoupledTest,
160
162
  NoQueryWithManyJoins.id: NoQueryWithManyJoins,
161
163
  NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
162
164
  NoSelectStar.id: NoSelectStar,
@@ -0,0 +1,107 @@
1
+ """SARJ412 — tests must not assert on raw IaC source text.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_iac_source_coupled_test.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
+ Diagnostic,
14
+ ExampleFile,
15
+ ExampleOutcome,
16
+ Rule,
17
+ RuleCategory,
18
+ RuleDocumentation,
19
+ RuleExample,
20
+ Severity,
21
+ parse_or_none,
22
+ )
23
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
24
+ from sarj_python_lint.rules.source_coupled_test import FunctionAnalyzer, top_level_test_functions
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from pathlib import Path
29
+
30
+
31
+ IAC_SOURCE_SUFFIXES = (".hcl", ".tf", ".tf.json", ".tfvars", ".tftest.hcl", ".tftest.json")
32
+
33
+
34
+ @final
35
+ class IacSourceCoupledTest(Rule):
36
+ id = "iac-source-coupled-test"
37
+ code = "SARJ412"
38
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
39
+ summary="Test asserts on raw IaC source text instead of a parsed plan, provider state, or runtime behavior.",
40
+ rationale=(
41
+ "Substring and regex checks can pass on comments, formatting, or unreachable Terraform configuration while clients fail silently."
42
+ ),
43
+ remediation="Parse rendered plan JSON, query the provider, or exercise the deployed runtime contract.",
44
+ category=RuleCategory.TESTING,
45
+ limitations=(
46
+ "The rule follows local aliases, path collections, context-managed reads, and common normalization; interprocedural flows remain unreported.",
47
+ "Files produced beneath recognized temporary-directory fixtures are generated outputs and remain unreported.",
48
+ "The warning-stage rule remains suppressible for calibration; promotion may make the locked policy non-suppressible.",
49
+ ),
50
+ examples=(
51
+ RuleExample(
52
+ example_id="rendered-plan-contract",
53
+ title="Assert on rendered Terraform plan behavior",
54
+ outcome=ExampleOutcome.NO_MATCH,
55
+ files=(
56
+ ExampleFile.python(
57
+ "tests/test_policy.py",
58
+ "def test_policy():\n plan = json.loads(Path('plan.json').read_text())\n assert verify(plan) == []\n",
59
+ ),
60
+ ),
61
+ focus_path=PurePosixPath("tests/test_policy.py"),
62
+ expected_count=0,
63
+ public=True,
64
+ ),
65
+ RuleExample(
66
+ example_id="terraform-substring-contract",
67
+ title="Do not prove Terraform behavior with a substring",
68
+ outcome=ExampleOutcome.MATCH,
69
+ files=(
70
+ ExampleFile.python(
71
+ "tests/test_policy.py",
72
+ "def test_policy():\n source = Path('main.tf').read_text()\n assert 'prevent_destroy = true' in source\n",
73
+ ),
74
+ ),
75
+ focus_path=PurePosixPath("tests/test_policy.py"),
76
+ expected_count=1,
77
+ public=True,
78
+ ),
79
+ ),
80
+ )
81
+ description = documentation.summary
82
+
83
+ @override
84
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
85
+ if not is_test_path(path) or is_generated(path, source):
86
+ return []
87
+ tree = parse_or_none(path, source)
88
+ if not isinstance(tree, ast.Module):
89
+ return []
90
+ assertions = [
91
+ assertion
92
+ for function, unittest_style in top_level_test_functions(tree)
93
+ for assertion in FunctionAnalyzer(IAC_SOURCE_SUFFIXES, unittest_style=unittest_style).analyze(function)
94
+ ]
95
+ return [
96
+ Diagnostic(
97
+ path=path,
98
+ line=assertion.lineno,
99
+ col=assertion.col_offset + 1,
100
+ code=self.code,
101
+ severity=Severity.WARNING,
102
+ message=(
103
+ "raw IaC source text is the test oracle; inspect rendered plan JSON, provider state, or runtime behavior instead."
104
+ ),
105
+ )
106
+ for assertion in assertions
107
+ ]
@@ -27,15 +27,12 @@ if TYPE_CHECKING:
27
27
  from pathlib import Path
28
28
 
29
29
 
30
- _SOURCE_SUFFIXES = (
30
+ GENERAL_SOURCE_SUFFIXES = (
31
31
  ".bash",
32
- ".hcl",
33
32
  ".js",
34
33
  ".mjs",
35
34
  ".py",
36
35
  ".sh",
37
- ".tf",
38
- ".tfvars",
39
36
  ".ts",
40
37
  ".yaml",
41
38
  ".yml",
@@ -46,6 +43,26 @@ _TEXT_TRANSFORMS = frozenset(
46
43
  _TEXT_ASSERTIONS = frozenset({"count", "endswith", "find", "index", "startswith"})
47
44
  _REGEX_ASSERTIONS = frozenset({"findall", "finditer", "fullmatch", "match", "search"})
48
45
  _TEMP_PATH_NAMES = frozenset({"tmp_path", "tmpdir", "temp_dir", "temporary_directory"})
46
+ _UNITTEST_ASSERTIONS = frozenset(
47
+ {
48
+ "assertEqual",
49
+ "assertFalse",
50
+ "assertGreater",
51
+ "assertGreaterEqual",
52
+ "assertIn",
53
+ "assertIs",
54
+ "assertIsNone",
55
+ "assertIsNot",
56
+ "assertIsNotNone",
57
+ "assertNotEqual",
58
+ "assertNotIn",
59
+ "assertNotRegex",
60
+ "assertRegex",
61
+ "assertLess",
62
+ "assertLessEqual",
63
+ "assertTrue",
64
+ }
65
+ )
49
66
 
50
67
 
51
68
  @final
@@ -55,7 +72,7 @@ class SourceCoupledTest(Rule):
55
72
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
56
73
  summary="Test asserts on raw repository source text instead of parsed or executable behavior.",
57
74
  rationale="Substring and regex checks can pass on comments or unreachable configuration and fail after behavior-preserving formatting changes.",
58
- remediation="Parse the artifact, execute its validator, or assert on Terraform plan JSON or another runtime contract.",
75
+ remediation="Parse the artifact, execute its validator, or assert on a runtime contract.",
59
76
  category=RuleCategory.TESTING,
60
77
  limitations=(
61
78
  "The rule follows local aliases, path aliases, context-managed reads, and common text normalization; interprocedural flows remain unreported.",
@@ -64,13 +81,13 @@ class SourceCoupledTest(Rule):
64
81
  ),
65
82
  examples=(
66
83
  RuleExample(
67
- example_id="parsed-terraform-contract",
68
- title="Assert on parsed plan behavior",
84
+ example_id="parsed-workflow-contract",
85
+ title="Assert on parsed workflow behavior",
69
86
  outcome=ExampleOutcome.NO_MATCH,
70
87
  files=(
71
88
  ExampleFile.python(
72
89
  "tests/test_policy.py",
73
- "def test_policy():\n plan = json.loads(Path('plan.json').read_text())\n assert verify(plan) == []\n",
90
+ "def test_policy():\n workflow = yaml.safe_load(Path('workflow.yml').read_text())\n assert verify(workflow) == []\n",
74
91
  ),
75
92
  ),
76
93
  focus_path=PurePosixPath("tests/test_policy.py"),
@@ -78,13 +95,13 @@ class SourceCoupledTest(Rule):
78
95
  public=True,
79
96
  ),
80
97
  RuleExample(
81
- example_id="terraform-substring-contract",
82
- title="Do not prove Terraform behavior with a substring",
98
+ example_id="workflow-substring-contract",
99
+ title="Do not prove workflow behavior with a substring",
83
100
  outcome=ExampleOutcome.MATCH,
84
101
  files=(
85
102
  ExampleFile.python(
86
103
  "tests/test_policy.py",
87
- "def test_policy():\n source = Path('main.tf').read_text()\n assert 'prevent_destroy = true' in source\n",
104
+ "def test_policy():\n source = Path('workflow.yml').read_text()\n assert 'permissions:' in source\n",
88
105
  ),
89
106
  ),
90
107
  focus_path=PurePosixPath("tests/test_policy.py"),
@@ -104,8 +121,8 @@ class SourceCoupledTest(Rule):
104
121
  return []
105
122
  assertions = [
106
123
  assertion
107
- for function in _top_level_test_functions(tree)
108
- for assertion in _FunctionAnalyzer().analyze(function)
124
+ for function, unittest_style in top_level_test_functions(tree)
125
+ for assertion in FunctionAnalyzer(GENERAL_SOURCE_SUFFIXES, unittest_style=unittest_style).analyze(function)
109
126
  ]
110
127
  return [
111
128
  Diagnostic(
@@ -122,33 +139,43 @@ class SourceCoupledTest(Rule):
122
139
  ]
123
140
 
124
141
 
125
- def _top_level_test_functions(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
126
- functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
142
+ def top_level_test_functions(tree: ast.Module) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, bool]]:
143
+ functions: list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, bool]] = []
127
144
  for statement in tree.body:
128
145
  if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) and statement.name.startswith("test"):
129
- functions.append(statement)
146
+ functions.append((statement, False))
130
147
  elif isinstance(statement, ast.ClassDef):
148
+ unittest_style = any(
149
+ (isinstance(base, ast.Name) and base.id == "TestCase")
150
+ or (isinstance(base, ast.Attribute) and base.attr == "TestCase")
151
+ for base in statement.bases
152
+ )
131
153
  functions.extend(
132
- child
154
+ (child, unittest_style)
133
155
  for child in statement.body
134
156
  if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test")
135
157
  )
136
158
  return functions
137
159
 
138
160
 
139
- class _FunctionAnalyzer(ast.NodeVisitor):
161
+ class FunctionAnalyzer(ast.NodeVisitor):
140
162
  """Track raw text inside one test without leaking into nested lexical scopes."""
141
163
 
142
- def __init__(self) -> None:
164
+ _source_suffixes: tuple[str, ...]
165
+ _unittest_style: bool
166
+
167
+ def __init__(self, source_suffixes: tuple[str, ...], *, unittest_style: bool = False) -> None:
168
+ self._source_suffixes = source_suffixes
169
+ self._unittest_style = unittest_style
143
170
  self._collections: set[str] = set()
144
171
  self._ephemeral_paths: set[str] = set()
145
172
  self._paths: set[str] = set()
146
173
  self._raw: set[str] = set()
147
174
  self._raw_origins: dict[str, set[int]] = {}
148
175
  self._reported_origins: set[int] = set()
149
- self._assertions: list[ast.Assert] = []
176
+ self._assertions: list[ast.Assert | ast.Call] = []
150
177
 
151
- def analyze(self, function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.Assert]:
178
+ def analyze(self, function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.Assert | ast.Call]:
152
179
  arguments = [*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs]
153
180
  self._ephemeral_paths.update(argument.arg for argument in arguments if argument.arg in _TEMP_PATH_NAMES)
154
181
  for statement in function.body:
@@ -194,7 +221,7 @@ class _FunctionAnalyzer(ast.NodeVisitor):
194
221
  for item in node.items:
195
222
  self.visit(item.context_expr)
196
223
  if isinstance(item.optional_vars, ast.Name) and _is_source_open(
197
- item.context_expr, self._paths, self._ephemeral_paths
224
+ item.context_expr, self._paths, self._ephemeral_paths, self._source_suffixes
198
225
  ):
199
226
  self._raw.add(item.optional_vars.id)
200
227
  self._raw_origins[item.optional_vars.id] = {item.context_expr.lineno}
@@ -207,10 +234,23 @@ class _FunctionAnalyzer(ast.NodeVisitor):
207
234
 
208
235
  @override
209
236
  def visit_Assert(self, node: ast.Assert) -> None:
210
- if _raw_text_oracle(node.test, self._raw, self._paths, self._ephemeral_paths):
211
- origins = _expression_origins(node.test, self._raw_origins, self._paths, self._ephemeral_paths) or {
212
- node.lineno
213
- }
237
+ if _raw_text_oracle(node.test, self._raw, self._paths, self._ephemeral_paths, self._source_suffixes):
238
+ origins = _expression_origins(
239
+ node.test, self._raw_origins, self._paths, self._ephemeral_paths, self._source_suffixes
240
+ ) or {node.lineno}
241
+ if not origins.issubset(self._reported_origins):
242
+ self._assertions.append(node)
243
+ self._reported_origins.update(origins)
244
+ self.generic_visit(node)
245
+
246
+ @override
247
+ def visit_Call(self, node: ast.Call) -> None:
248
+ if self._unittest_style and _unittest_raw_text_oracle(
249
+ node, self._raw, self._paths, self._ephemeral_paths, self._source_suffixes
250
+ ):
251
+ origins = _expression_origins(
252
+ node, self._raw_origins, self._paths, self._ephemeral_paths, self._source_suffixes
253
+ ) or {node.lineno}
214
254
  if not origins.issubset(self._reported_origins):
215
255
  self._assertions.append(node)
216
256
  self._reported_origins.update(origins)
@@ -234,7 +274,9 @@ class _FunctionAnalyzer(ast.NodeVisitor):
234
274
  def _record_target(self, target: ast.expr, value: ast.expr) -> None:
235
275
  if not isinstance(target, ast.Name):
236
276
  return
237
- raw_origins = _expression_origins(value, self._raw_origins, self._paths, self._ephemeral_paths)
277
+ raw_origins = _expression_origins(
278
+ value, self._raw_origins, self._paths, self._ephemeral_paths, self._source_suffixes
279
+ )
238
280
  # Reassignment kills stale taint before adding the new value's state.
239
281
  self._paths.discard(target.id)
240
282
  self._raw.discard(target.id)
@@ -243,36 +285,43 @@ class _FunctionAnalyzer(ast.NodeVisitor):
243
285
  self._ephemeral_paths.discard(target.id)
244
286
  if _ephemeral_path_expression(value, self._ephemeral_paths):
245
287
  self._ephemeral_paths.add(target.id)
246
- if _source_path_collection(value, self._paths):
288
+ if _source_path_collection(value, self._paths, self._source_suffixes):
247
289
  self._collections.add(target.id)
248
- if _source_path_expression(value, self._paths):
290
+ if _source_path_expression(value, self._paths, self._source_suffixes):
249
291
  self._paths.add(target.id)
250
- if _raw_text_expression(value, self._raw, self._paths, self._ephemeral_paths):
292
+ if _raw_text_expression(value, self._raw, self._paths, self._ephemeral_paths, self._source_suffixes):
251
293
  self._raw.add(target.id)
252
294
  self._raw_origins[target.id] = raw_origins or {value.lineno}
253
295
 
254
296
 
255
- def _is_source_open(node: ast.expr, path_names: set[str], ephemeral_path_names: set[str]) -> bool:
297
+ def _is_source_open(
298
+ node: ast.expr,
299
+ path_names: set[str],
300
+ ephemeral_path_names: set[str],
301
+ source_suffixes: tuple[str, ...],
302
+ ) -> bool:
256
303
  return (
257
304
  isinstance(node, ast.Call)
258
305
  and isinstance(node.func, ast.Name)
259
306
  and node.func.id == "open"
260
307
  and bool(node.args)
261
- and _source_path_expression(node.args[0], path_names)
308
+ and _source_path_expression(node.args[0], path_names, source_suffixes)
262
309
  and not _ephemeral_path_expression(node.args[0], ephemeral_path_names)
263
310
  )
264
311
 
265
312
 
266
- def _source_path_expression(node: ast.AST, path_names: set[str]) -> bool:
313
+ def _source_path_expression(node: ast.AST, path_names: set[str], source_suffixes: tuple[str, ...]) -> bool:
267
314
  match node:
268
315
  case ast.Name(id=name):
269
316
  return name in path_names
270
317
  case ast.Constant(value=str(value)):
271
- return value.lower().endswith(_SOURCE_SUFFIXES)
318
+ return value.lower().endswith(source_suffixes)
272
319
  case ast.JoinedStr(values=values):
273
- return any(_source_path_expression(value, path_names) for value in values)
320
+ return any(_source_path_expression(value, path_names, source_suffixes) for value in values)
274
321
  case ast.BinOp() | ast.Call() | ast.Attribute() | ast.Subscript():
275
- return any(_source_path_expression(child, path_names) for child in ast.iter_child_nodes(node))
322
+ return any(
323
+ _source_path_expression(child, path_names, source_suffixes) for child in ast.iter_child_nodes(node)
324
+ )
276
325
  case _:
277
326
  return False
278
327
 
@@ -281,77 +330,168 @@ def _ephemeral_path_expression(node: ast.AST, ephemeral_path_names: set[str]) ->
281
330
  return any(isinstance(child, ast.Name) and child.id in ephemeral_path_names for child in ast.walk(node))
282
331
 
283
332
 
284
- def _source_path_collection(node: ast.AST, path_names: set[str]) -> bool:
333
+ def _source_path_collection(node: ast.AST, path_names: set[str], source_suffixes: tuple[str, ...]) -> bool:
285
334
  return (
286
335
  isinstance(node, (ast.List, ast.Set, ast.Tuple))
287
336
  and bool(node.elts)
288
- and all(_source_path_expression(element, path_names) for element in node.elts)
337
+ and all(_source_path_expression(element, path_names, source_suffixes) for element in node.elts)
289
338
  )
290
339
 
291
340
 
292
- def _raw_source_read(node: ast.expr, path_names: set[str], ephemeral_path_names: set[str]) -> bool:
341
+ def _raw_source_read(
342
+ node: ast.expr,
343
+ path_names: set[str],
344
+ ephemeral_path_names: set[str],
345
+ source_suffixes: tuple[str, ...],
346
+ ) -> bool:
293
347
  if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
294
348
  return False
295
349
  if node.func.attr == "read_text":
296
- return _source_path_expression(node.func.value, path_names) and not _ephemeral_path_expression(
350
+ return _source_path_expression(node.func.value, path_names, source_suffixes) and not _ephemeral_path_expression(
297
351
  node.func.value, ephemeral_path_names
298
352
  )
299
- return node.func.attr == "read" and _is_source_open(node.func.value, path_names, ephemeral_path_names)
353
+ return node.func.attr == "read" and _is_source_open(
354
+ node.func.value, path_names, ephemeral_path_names, source_suffixes
355
+ )
300
356
 
301
357
 
302
358
  def _raw_text_expression(
303
- node: ast.expr, raw_names: set[str], path_names: set[str], ephemeral_path_names: set[str]
359
+ node: ast.expr,
360
+ raw_names: set[str],
361
+ path_names: set[str],
362
+ ephemeral_path_names: set[str],
363
+ source_suffixes: tuple[str, ...],
304
364
  ) -> bool:
305
365
  if isinstance(node, ast.Name):
306
366
  return node.id in raw_names
307
- if _raw_source_read(node, path_names, ephemeral_path_names):
367
+ if _raw_source_read(node, path_names, ephemeral_path_names, source_suffixes):
308
368
  return True
309
369
  if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
310
370
  if node.func.attr == "read" and isinstance(node.func.value, ast.Name):
311
371
  return node.func.value.id in raw_names
312
372
  return node.func.attr in _TEXT_TRANSFORMS and _raw_text_expression(
313
- node.func.value, raw_names, path_names, ephemeral_path_names
373
+ node.func.value, raw_names, path_names, ephemeral_path_names, source_suffixes
314
374
  )
315
375
  if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
316
- return _raw_text_expression(node.left, raw_names, path_names, ephemeral_path_names) or _raw_text_expression(
317
- node.right, raw_names, path_names, ephemeral_path_names
318
- )
376
+ return _raw_text_expression(
377
+ node.left, raw_names, path_names, ephemeral_path_names, source_suffixes
378
+ ) or _raw_text_expression(node.right, raw_names, path_names, ephemeral_path_names, source_suffixes)
319
379
  return False
320
380
 
321
381
 
322
- def _raw_text_oracle(node: ast.expr, raw_names: set[str], path_names: set[str], ephemeral_path_names: set[str]) -> bool:
382
+ def _raw_text_oracle(
383
+ node: ast.expr,
384
+ raw_names: set[str],
385
+ path_names: set[str],
386
+ ephemeral_path_names: set[str],
387
+ source_suffixes: tuple[str, ...],
388
+ ) -> bool:
323
389
  if isinstance(node, ast.Compare):
324
390
  operands = [node.left, *node.comparators]
325
391
  if any(
326
- _raw_text_expression(operand, raw_names, path_names, ephemeral_path_names) for operand in operands
392
+ _raw_text_measurement(operand, raw_names, path_names, ephemeral_path_names, source_suffixes)
393
+ for operand in operands
394
+ ) and any(isinstance(operator, (ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE)) for operator in node.ops):
395
+ return True
396
+ if any(
397
+ _raw_text_expression(operand, raw_names, path_names, ephemeral_path_names, source_suffixes)
398
+ for operand in operands
327
399
  ) and any(isinstance(operator, (ast.In, ast.NotIn, ast.Eq, ast.NotEq)) for operator in node.ops):
328
400
  return True
401
+ if (
402
+ isinstance(node, ast.Call)
403
+ and isinstance(node.func, ast.Name)
404
+ and node.func.id in {"all", "any"}
405
+ and any(
406
+ isinstance(argument, ast.GeneratorExp)
407
+ and any(
408
+ _raw_text_line_iteration(item.iter, raw_names, path_names, ephemeral_path_names, source_suffixes)
409
+ for item in argument.generators
410
+ )
411
+ for argument in node.args
412
+ )
413
+ ):
414
+ return True
329
415
  if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
330
416
  if node.func.attr in _TEXT_ASSERTIONS and _raw_text_expression(
331
- node.func.value, raw_names, path_names, ephemeral_path_names
417
+ node.func.value, raw_names, path_names, ephemeral_path_names, source_suffixes
332
418
  ):
333
419
  return True
334
420
  if node.func.attr in _REGEX_ASSERTIONS and any(
335
- _raw_text_expression(argument, raw_names, path_names, ephemeral_path_names) for argument in node.args
421
+ _raw_text_expression(argument, raw_names, path_names, ephemeral_path_names, source_suffixes)
422
+ for argument in node.args
336
423
  ):
337
424
  return True
338
425
  return any(
339
- _raw_text_oracle(child, raw_names, path_names, ephemeral_path_names)
426
+ _raw_text_oracle(child, raw_names, path_names, ephemeral_path_names, source_suffixes)
340
427
  for child in ast.iter_child_nodes(node)
341
428
  if isinstance(child, ast.expr)
342
429
  )
343
430
 
344
431
 
432
+ def _raw_text_measurement(
433
+ node: ast.expr,
434
+ raw_names: set[str],
435
+ path_names: set[str],
436
+ ephemeral_path_names: set[str],
437
+ source_suffixes: tuple[str, ...],
438
+ ) -> bool:
439
+ return (
440
+ isinstance(node, ast.Call)
441
+ and isinstance(node.func, ast.Name)
442
+ and node.func.id == "len"
443
+ and len(node.args) == 1
444
+ and _raw_text_expression(node.args[0], raw_names, path_names, ephemeral_path_names, source_suffixes)
445
+ )
446
+
447
+
448
+ def _raw_text_line_iteration(
449
+ node: ast.expr,
450
+ raw_names: set[str],
451
+ path_names: set[str],
452
+ ephemeral_path_names: set[str],
453
+ source_suffixes: tuple[str, ...],
454
+ ) -> bool:
455
+ return (
456
+ isinstance(node, ast.Call)
457
+ and isinstance(node.func, ast.Attribute)
458
+ and node.func.attr == "splitlines"
459
+ and _raw_text_expression(node.func.value, raw_names, path_names, ephemeral_path_names, source_suffixes)
460
+ )
461
+
462
+
463
+ def _unittest_raw_text_oracle(
464
+ node: ast.Call,
465
+ raw_names: set[str],
466
+ path_names: set[str],
467
+ ephemeral_path_names: set[str],
468
+ source_suffixes: tuple[str, ...],
469
+ ) -> bool:
470
+ return (
471
+ isinstance(node.func, ast.Attribute)
472
+ and isinstance(node.func.value, ast.Name)
473
+ and node.func.value.id in {"self", "cls"}
474
+ and node.func.attr in _UNITTEST_ASSERTIONS
475
+ and any(
476
+ _raw_text_expression(argument, raw_names, path_names, ephemeral_path_names, source_suffixes)
477
+ or _raw_text_measurement(argument, raw_names, path_names, ephemeral_path_names, source_suffixes)
478
+ or _raw_text_oracle(argument, raw_names, path_names, ephemeral_path_names, source_suffixes)
479
+ for argument in node.args
480
+ )
481
+ )
482
+
483
+
345
484
  def _expression_origins(
346
485
  node: ast.AST,
347
486
  raw_origins: dict[str, set[int]],
348
487
  path_names: set[str],
349
488
  ephemeral_path_names: set[str],
489
+ source_suffixes: tuple[str, ...],
350
490
  ) -> set[int]:
351
491
  origins: set[int] = set()
352
492
  for child in ast.walk(node):
353
493
  if isinstance(child, ast.Name):
354
494
  origins.update(raw_origins.get(child.id, set()))
355
- elif isinstance(child, ast.Call) and _raw_source_read(child, path_names, ephemeral_path_names):
495
+ elif isinstance(child, ast.Call) and _raw_source_read(child, path_names, ephemeral_path_names, source_suffixes):
356
496
  origins.add(child.lineno)
357
497
  return origins