sarj-python-lint 0.67.14__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.67.14 → sarj_python_lint-0.69.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_registry.py +6 -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.69.0/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +446 -0
  6. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/source_coupled_test.py +193 -53
  7. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/.gitignore +0 -0
  8. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/LICENSE +0 -0
  9. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/README.md +0 -0
  10. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  29. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  30. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  31. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  32. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  33. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  34. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  35. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  36. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  37. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  38. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  39. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  40. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  41. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  42. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  43. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  44. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  45. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  46. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  47. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  48. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  49. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  50. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  51. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  52. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  53. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  54. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  55. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  56. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  57. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  58. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  60. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  61. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  62. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  63. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  64. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  65. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  66. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  67. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  68. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  69. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  70. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  71. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  72. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  73. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  74. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  75. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  76. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  77. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  78. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  79. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  80. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  81. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  82. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  83. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  84. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  85. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  86. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  87. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  88. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  89. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  90. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  91. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  92. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  93. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  94. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  95. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  96. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  97. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  98. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  99. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  100. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  101. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  102. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  103. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  104. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  105. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  106. {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  107. {sarj_python_lint-0.67.14 → 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.67.14
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.67.14"
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
  )
@@ -117,6 +118,9 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
117
118
  RequireKeywordOnlySwapProneParams,
118
119
  )
119
120
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
121
+ from sarj_python_lint.rules.require_pydantic_for_external_json import (
122
+ RequirePydanticForExternalJson,
123
+ )
120
124
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
121
125
  from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
122
126
  from sarj_python_lint.rules.stepdown import Stepdown
@@ -154,6 +158,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
154
158
  NoCommentCruft.id: NoCommentCruft,
155
159
  StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
156
160
  SourceCoupledTest.id: SourceCoupledTest,
161
+ IacSourceCoupledTest.id: IacSourceCoupledTest,
157
162
  NoQueryWithManyJoins.id: NoQueryWithManyJoins,
158
163
  NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
159
164
  NoSelectStar.id: NoSelectStar,
@@ -162,6 +167,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
162
167
  NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
163
168
  PreferMatchAssertNever.id: PreferMatchAssertNever,
164
169
  RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
170
+ RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
165
171
  NoFileLevelSuppression.id: NoFileLevelSuppression,
166
172
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
167
173
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
@@ -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
+ ]
@@ -0,0 +1,446 @@
1
+ """SARJ411 — Validate external JSON before reading fixed fields.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_require_pydantic_for_external_json.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from collections import Counter
10
+ from dataclasses import dataclass
11
+ from pathlib import PurePosixPath
12
+ from typing import TYPE_CHECKING, ClassVar, 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._imports import ImportIndex
27
+ from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ from pathlib import Path
32
+
33
+
34
+ _JSON_MODULES = frozenset({"json", "orjson", "rapidjson", "ujson"})
35
+ _HTTP_MODULES = frozenset({"httpx", "requests"})
36
+ _HTTP_METHODS = frozenset({"delete", "get", "head", "options", "patch", "post", "put", "request"})
37
+ _OBJECT_VALIDATORS = frozenset({"model_validate", "parse_obj"})
38
+ _ADAPTER_VALIDATORS = frozenset({"validate_python"})
39
+ _RECORD_METHODS = frozenset({"get", "items", "keys", "values"})
40
+ _LOCAL_READ_METHODS = frozenset({"read_bytes", "read_text"})
41
+ _DOCUMENTATION_DIR_NAMES = frozenset({"docs", "docs_src", "examples"})
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class _ModuleSummaries:
46
+ decoder_parameters: dict[str, int]
47
+ record_parameters: dict[str, frozenset[int]]
48
+ local_parameters: dict[str, frozenset[int]]
49
+
50
+
51
+ @final
52
+ class RequirePydanticForExternalJson(Rule):
53
+ id = "require-pydantic-for-external-json"
54
+ code = "SARJ411"
55
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
56
+ summary="Externally sourced JSON is consumed without runtime schema validation.",
57
+ rationale=(
58
+ "Annotations, casts, key-by-key checks, and dictionary access do not validate a wire protocol; "
59
+ "Pydantic makes required fields, types, and protocol versions explicit at the boundary."
60
+ ),
61
+ remediation=(
62
+ "Use `Model.model_validate_json(payload)` or `TypeAdapter(Model).validate_json(payload)`, or "
63
+ "validate an already-decoded value with `model_validate` or `validate_python` before use."
64
+ ),
65
+ category=RuleCategory.CORRECTNESS,
66
+ autofix=AutofixPolicy.NONE,
67
+ limitations=(
68
+ "The rule follows common JSON decoders and simple module-local helpers through single-assignment names.",
69
+ "It diagnoses literal-key record access; dynamic JSON documents without fixed-field access remain out of scope.",
70
+ "Repository-local JSON, json.load file handles, tests, generated files, and documentation examples are excluded.",
71
+ ),
72
+ examples=(
73
+ RuleExample(
74
+ example_id="manual-external-json-access",
75
+ title="External JSON read as a dictionary",
76
+ outcome=ExampleOutcome.MATCH,
77
+ files=(
78
+ ExampleFile.python(
79
+ "protocol.py",
80
+ "import json\n\ndef parse(payload: str) -> object:\n report = json.loads(payload)\n return report.get('version')\n",
81
+ ),
82
+ ),
83
+ focus_path=PurePosixPath("protocol.py"),
84
+ expected_count=1,
85
+ public=True,
86
+ ),
87
+ RuleExample(
88
+ example_id="pydantic-external-json-validation",
89
+ title="External JSON validated by a boundary model",
90
+ outcome=ExampleOutcome.NO_MATCH,
91
+ files=(
92
+ ExampleFile.python(
93
+ "protocol.py",
94
+ "def parse(payload: str) -> Report:\n return Report.model_validate_json(payload)\n",
95
+ ),
96
+ ),
97
+ focus_path=PurePosixPath("protocol.py"),
98
+ expected_count=0,
99
+ public=True,
100
+ ),
101
+ ),
102
+ )
103
+ description = documentation.summary
104
+
105
+ @override
106
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
107
+ if _excluded(path, source) or ("loads" not in source and ".json(" not in source):
108
+ return []
109
+ tree = parse_or_none(path, source)
110
+ if tree is None:
111
+ return []
112
+ imports = ImportIndex.from_tree(tree)
113
+ summaries = _module_summaries(tree, imports)
114
+ findings: list[tuple[ast.expr, ast.Call]] = []
115
+ for function in _functions(tree):
116
+ findings.extend(_function_findings(function, imports, summaries))
117
+
118
+ first_by_origin: dict[int, tuple[ast.expr, ast.Call]] = {}
119
+ for sink, origin in sorted(findings, key=lambda item: (item[0].lineno, item[0].col_offset)):
120
+ first_by_origin.setdefault(id(origin), (sink, origin))
121
+ return [
122
+ Diagnostic(
123
+ path=path,
124
+ line=sink.lineno,
125
+ col=sink.col_offset + 1,
126
+ code=self.code,
127
+ message=(
128
+ "External JSON field is read without Pydantic validation — validate the payload with "
129
+ "`Model.model_validate_json(...)` or `TypeAdapter(Model).validate_json(...)` first."
130
+ ),
131
+ severity=Severity.WARNING,
132
+ )
133
+ for sink, _origin in first_by_origin.values()
134
+ ]
135
+
136
+
137
+ def _functions(tree: ast.Module) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ...]:
138
+ return tuple(node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
139
+
140
+
141
+ def _module_summaries(tree: ast.Module, imports: ImportIndex) -> _ModuleSummaries:
142
+ functions = tuple(
143
+ statement for statement in tree.body if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
144
+ )
145
+ duplicate_names = {name for name, count in Counter(function.name for function in functions).items() if count > 1}
146
+ decoders: dict[str, int] = {}
147
+ records: dict[str, frozenset[int]] = {}
148
+ for function in functions:
149
+ if function.name in duplicate_names:
150
+ continue
151
+ parameters = _parameter_positions(function)
152
+ returned = tuple(node.value for node in _own_scope(function) if isinstance(node, ast.Return) and node.value)
153
+ decoder_positions = {
154
+ position
155
+ for value in returned
156
+ if (position := _decoded_parameter_position(value, parameters, imports)) is not None
157
+ }
158
+ if (
159
+ returned
160
+ and len(decoder_positions) == 1
161
+ and all(_decoded_parameter_position(value, parameters, imports) in decoder_positions for value in returned)
162
+ ):
163
+ decoders[function.name] = decoder_positions.pop()
164
+
165
+ consumed: set[int] = set()
166
+ for node in _own_scope(function):
167
+ receiver = _summary_record_receiver(node)
168
+ if isinstance(receiver, ast.Name) and receiver.id in parameters:
169
+ consumed.add(parameters[receiver.id])
170
+ if consumed:
171
+ records[function.name] = frozenset(consumed)
172
+ return _ModuleSummaries(decoders, records, _locally_sourced_parameters(functions))
173
+
174
+
175
+ def _locally_sourced_parameters(
176
+ functions: tuple[ast.FunctionDef | ast.AsyncFunctionDef, ...],
177
+ ) -> dict[str, frozenset[int]]:
178
+ calls: dict[str, list[ast.Call]] = {}
179
+ for owner in functions:
180
+ for node in _own_scope(owner):
181
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
182
+ calls.setdefault(node.func.id, []).append(node)
183
+ local: dict[str, frozenset[int]] = {}
184
+ for function in functions:
185
+ callsites = calls.get(function.name, [])
186
+ if not callsites:
187
+ continue
188
+ positions = {
189
+ position
190
+ for position in range(len(_parameter_positions(function)))
191
+ if all(position < len(call.args) and _is_local_json_text(call.args[position]) for call in callsites)
192
+ }
193
+ if positions:
194
+ local[function.name] = frozenset(positions)
195
+ return local
196
+
197
+
198
+ def _decoded_parameter_position(
199
+ value: ast.expr,
200
+ parameters: dict[str, int],
201
+ imports: ImportIndex,
202
+ ) -> int | None:
203
+ value = _unwrap_await(value)
204
+ if not isinstance(value, ast.Call) or not _is_json_loads(value, imports) or not value.args:
205
+ return None
206
+ argument = value.args[0]
207
+ return parameters.get(argument.id) if isinstance(argument, ast.Name) else None
208
+
209
+
210
+ def _parameter_positions(function: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, int]:
211
+ arguments = (*function.args.posonlyargs, *function.args.args)
212
+ return {argument.arg: position for position, argument in enumerate(arguments)}
213
+
214
+
215
+ def _function_findings(
216
+ function: ast.FunctionDef | ast.AsyncFunctionDef,
217
+ imports: ImportIndex,
218
+ summaries: _ModuleSummaries,
219
+ ) -> list[tuple[ast.expr, ast.Call]]:
220
+ scope = _own_scope(function)
221
+ local_positions = summaries.local_parameters.get(function.name, frozenset())
222
+ parameters = frozenset(
223
+ name for name, position in _parameter_positions(function).items() if position not in local_positions
224
+ )
225
+ outbound_requests = _outbound_request_parameters(function, imports)
226
+ response_names = _http_response_names(scope, imports)
227
+ bindings = _unique_bindings(scope)
228
+ resolver = _OriginResolver(imports, summaries, parameters, outbound_requests, response_names, bindings)
229
+ findings: list[tuple[ast.expr, ast.Call]] = []
230
+ for node in scope:
231
+ access = _record_access(node)
232
+ if access is not None:
233
+ sink, receiver = access
234
+ findings.extend((sink, origin) for origin in resolver.origins(receiver))
235
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
236
+ for position in summaries.record_parameters.get(node.func.id, frozenset()):
237
+ if position < len(node.args):
238
+ findings.extend((node, origin) for origin in resolver.origins(node.args[position]))
239
+ return findings
240
+
241
+
242
+ @dataclass(slots=True)
243
+ class _OriginResolver:
244
+ imports: ImportIndex
245
+ summaries: _ModuleSummaries
246
+ parameters: frozenset[str]
247
+ outbound_request_parameters: frozenset[str]
248
+ response_names: frozenset[str]
249
+ bindings: dict[str, ast.expr]
250
+
251
+ def origins(self, expression: ast.expr, resolving: frozenset[str] = frozenset()) -> frozenset[ast.Call]:
252
+ expression = _unwrap_await(expression)
253
+ if isinstance(expression, ast.Name):
254
+ if expression.id in resolving or (value := self.bindings.get(expression.id)) is None:
255
+ return frozenset()
256
+ return self.origins(value, resolving | {expression.id})
257
+ if isinstance(expression, ast.Call):
258
+ if _is_validation_call(expression, self.imports) or _is_model_unpack_validation(expression):
259
+ return frozenset()
260
+ if self._is_source(expression):
261
+ return frozenset({expression})
262
+ found_origins: set[ast.Call] = set()
263
+ if (
264
+ isinstance(expression.func, ast.Name)
265
+ and (position := self.summaries.decoder_parameters.get(expression.func.id)) is not None
266
+ and position < len(expression.args)
267
+ and self._is_external_input(expression.args[position])
268
+ ):
269
+ found_origins.add(expression)
270
+ for argument in expression.args:
271
+ found_origins.update(self.origins(argument, resolving))
272
+ for keyword in expression.keywords:
273
+ found_origins.update(self.origins(keyword.value, resolving))
274
+ return frozenset(found_origins)
275
+ found_origins = set()
276
+ for child in ast.iter_child_nodes(expression):
277
+ if isinstance(child, ast.expr):
278
+ found_origins.update(self.origins(child, resolving))
279
+ return frozenset(found_origins)
280
+
281
+ def _is_source(self, call: ast.Call) -> bool:
282
+ if _is_json_loads(call, self.imports) and call.args:
283
+ return self._is_external_input(call.args[0])
284
+ return (
285
+ isinstance(call.func, ast.Attribute)
286
+ and call.func.attr == "json"
287
+ and not call.args
288
+ and isinstance(call.func.value, ast.Name)
289
+ and call.func.value.id in self.response_names
290
+ )
291
+
292
+ def _is_external_input(self, expression: ast.expr) -> bool:
293
+ expression = _unwrap_await(expression)
294
+ match expression:
295
+ case ast.Name(id=name):
296
+ return name in self.parameters
297
+ case ast.Attribute(value=ast.Name(id=owner), attr=attribute) if owner in self.outbound_request_parameters:
298
+ return False
299
+ case ast.Attribute(attr=attribute):
300
+ return attribute in {"content", "stderr", "stdout", "text"}
301
+ case ast.Subscript(value=value):
302
+ return _dotted_name(value) in {"environ", "os.environ"}
303
+ case _:
304
+ return not _is_local_json_text(expression)
305
+
306
+
307
+ def _record_access(node: ast.AST) -> tuple[ast.expr, ast.expr] | None:
308
+ if isinstance(node, ast.Subscript) and _literal_string(node.slice) is not None:
309
+ return node, node.value
310
+ if isinstance(node, ast.Call) and (receiver := _fixed_record_call_receiver(node)) is not None:
311
+ return node, receiver
312
+ return None
313
+
314
+
315
+ def _fixed_record_call_receiver(node: ast.Call) -> ast.expr | None:
316
+ if not isinstance(node.func, ast.Attribute) or node.func.attr not in _RECORD_METHODS:
317
+ return None
318
+ if node.func.attr == "get" and not (node.args and _literal_string(node.args[0]) is not None):
319
+ return None
320
+ return node.func.value
321
+
322
+
323
+ def _summary_record_receiver(node: ast.AST) -> ast.expr | None:
324
+ """Return a helper's record receiver even when its key is a parameter."""
325
+ if isinstance(node, ast.Subscript):
326
+ return node.value
327
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in _RECORD_METHODS:
328
+ return node.func.value
329
+ return None
330
+
331
+
332
+ def _unique_bindings(scope: tuple[ast.AST, ...]) -> dict[str, ast.expr]:
333
+ candidates: dict[str, list[ast.expr]] = {}
334
+ for node in scope:
335
+ if isinstance(node, ast.Assign):
336
+ for target in node.targets:
337
+ if isinstance(target, ast.Name):
338
+ candidates.setdefault(target.id, []).append(node.value)
339
+ elif (binding := _single_named_binding(node)) is not None:
340
+ name, value = binding
341
+ candidates.setdefault(name, []).append(value)
342
+ return {name: values[0] for name, values in candidates.items() if len(values) == 1}
343
+
344
+
345
+ def _single_named_binding(node: ast.AST) -> tuple[str, ast.expr] | None:
346
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None:
347
+ return node.target.id, node.value
348
+ if isinstance(node, ast.NamedExpr):
349
+ return node.target.id, node.value
350
+ return None
351
+
352
+
353
+ def _http_response_names(scope: tuple[ast.AST, ...], imports: ImportIndex) -> frozenset[str]:
354
+ names: set[str] = set()
355
+ for node in scope:
356
+ if not isinstance(node, (ast.Assign, ast.AnnAssign)) or node.value is None:
357
+ continue
358
+ call = _unwrap_await(node.value)
359
+ if not isinstance(call, ast.Call) or not any(
360
+ imports.resolves(call.func, sources=_HTTP_MODULES, symbol=method) for method in _HTTP_METHODS
361
+ ):
362
+ continue
363
+ targets = node.targets if isinstance(node, ast.Assign) else (node.target,)
364
+ names.update(target.id for target in targets if isinstance(target, ast.Name))
365
+ return frozenset(names)
366
+
367
+
368
+ def _outbound_request_parameters(
369
+ function: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex
370
+ ) -> frozenset[str]:
371
+ arguments = (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs)
372
+ return frozenset(
373
+ argument.arg
374
+ for argument in arguments
375
+ if argument.annotation is not None
376
+ and imports.resolves(argument.annotation, sources=_HTTP_MODULES, symbol="Request")
377
+ )
378
+
379
+
380
+ def _is_json_loads(call: ast.Call, imports: ImportIndex) -> bool:
381
+ return imports.resolves(call.func, sources=_JSON_MODULES, symbol="loads")
382
+
383
+
384
+ def _is_validation_call(call: ast.Call, imports: ImportIndex) -> bool:
385
+ if isinstance(call.func, ast.Attribute) and call.func.attr in (_OBJECT_VALIDATORS | _ADAPTER_VALIDATORS):
386
+ return True
387
+ return imports.resolves(call.func, sources=frozenset({"pydantic"}), symbol="parse_obj_as")
388
+
389
+
390
+ def _is_model_unpack_validation(call: ast.Call) -> bool:
391
+ return (
392
+ isinstance(call.func, ast.Name)
393
+ and call.func.id[:1].isupper()
394
+ and any(keyword.arg is None for keyword in call.keywords)
395
+ )
396
+
397
+
398
+ def _is_local_json_text(expression: ast.expr) -> bool:
399
+ expression = _unwrap_await(expression)
400
+ if isinstance(expression, ast.Constant) and isinstance(expression.value, (str, bytes)):
401
+ return True
402
+ return (
403
+ isinstance(expression, ast.Call)
404
+ and isinstance(expression.func, ast.Attribute)
405
+ and expression.func.attr in _LOCAL_READ_METHODS
406
+ )
407
+
408
+
409
+ def _dotted_name(node: ast.expr) -> str | None:
410
+ parts: list[str] = []
411
+ current = node
412
+ while isinstance(current, ast.Attribute):
413
+ parts.append(current.attr)
414
+ current = current.value
415
+ if not isinstance(current, ast.Name):
416
+ return None
417
+ return ".".join((current.id, *reversed(parts)))
418
+
419
+
420
+ def _literal_string(node: ast.expr) -> str | None:
421
+ return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
422
+
423
+
424
+ def _unwrap_await(node: ast.expr) -> ast.expr:
425
+ return node.value if isinstance(node, ast.Await) else node
426
+
427
+
428
+ def _excluded(path: Path, source: str) -> bool:
429
+ return (
430
+ is_test_path(path)
431
+ or is_test_support_path(path)
432
+ or is_generated(path, source)
433
+ or any(part.lower() in _DOCUMENTATION_DIR_NAMES for part in path.parts)
434
+ )
435
+
436
+
437
+ def _own_scope(function: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[ast.AST, ...]:
438
+ out: list[ast.AST] = []
439
+ stack: list[ast.AST] = list(reversed(function.body))
440
+ while stack:
441
+ node = stack.pop()
442
+ out.append(node)
443
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
444
+ continue
445
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
446
+ return tuple(out)