sarj-python-lint 0.67.14__tar.gz → 0.68.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  4. sarj_python_lint-0.68.0/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +446 -0
  5. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/.gitignore +0 -0
  6. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/LICENSE +0 -0
  7. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/README.md +0 -0
  8. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_filesystem.py +0 -0
  11. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  12. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_secret_names.py +0 -0
  13. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_version.py +0 -0
  14. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/py.typed +0 -0
  15. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/ratchet.py +0 -0
  16. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rule_base.py +0 -0
  17. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  18. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  19. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  20. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  21. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  22. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  24. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  25. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  26. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  27. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  28. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  29. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  30. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  31. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  32. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  33. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  34. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  35. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  36. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  37. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  38. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  39. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  40. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  41. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  42. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  43. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  44. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  45. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  46. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  47. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  48. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  49. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  50. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  51. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  52. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  53. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  54. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  55. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  56. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  57. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  58. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  59. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  60. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  61. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  62. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  63. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  64. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  65. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  66. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  67. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  68. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  69. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  70. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  71. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  72. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  73. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  74. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  75. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  76. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  77. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  78. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  79. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  80. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  81. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  82. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  83. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  84. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  85. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  86. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  87. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  88. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  89. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  90. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  91. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  92. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  93. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  94. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  95. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  96. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  97. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  98. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  99. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  100. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  101. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  102. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  103. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  104. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  105. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  106. {sarj_python_lint-0.67.14 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.67.14
3
+ Version: 0.68.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.67.14"
3
+ version = "0.68.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -117,6 +117,9 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
117
117
  RequireKeywordOnlySwapProneParams,
118
118
  )
119
119
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
120
+ from sarj_python_lint.rules.require_pydantic_for_external_json import (
121
+ RequirePydanticForExternalJson,
122
+ )
120
123
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
121
124
  from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
122
125
  from sarj_python_lint.rules.stepdown import Stepdown
@@ -162,6 +165,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
162
165
  NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
163
166
  PreferMatchAssertNever.id: PreferMatchAssertNever,
164
167
  RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
168
+ RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
165
169
  NoFileLevelSuppression.id: NoFileLevelSuppression,
166
170
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
167
171
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
@@ -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)