sarj-python-lint 0.67.10__tar.gz → 0.67.11__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 (100) hide show
  1. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. sarj_python_lint-0.67.11/src/sarj_python_lint/rules/source_coupled_test.py +357 -0
  5. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/.gitignore +0 -0
  6. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/LICENSE +0 -0
  7. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/README.md +0 -0
  8. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/_filesystem.py +0 -0
  11. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  12. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/_secret_names.py +0 -0
  13. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/_version.py +0 -0
  14. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/py.typed +0 -0
  15. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/ratchet.py +0 -0
  16. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rule_base.py +0 -0
  17. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/__init__.py +0 -0
  18. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  19. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_comments.py +0 -0
  20. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  21. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  22. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_imports.py +0 -0
  24. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_logging.py +0 -0
  25. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_paths.py +0 -0
  26. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  27. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_pytest.py +0 -0
  28. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_sql.py +0 -0
  29. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  30. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  31. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  32. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  33. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  34. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  35. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  36. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  37. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  38. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  39. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  40. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  41. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  42. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  43. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  44. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  45. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  46. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  47. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  48. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  49. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  50. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  51. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  52. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  53. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  54. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  55. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  56. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  57. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  58. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  59. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  60. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  61. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  62. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  63. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  64. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  65. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  66. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  67. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  68. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  69. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  70. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  71. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  72. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  73. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  74. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  75. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  76. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  77. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  78. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  79. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  80. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  81. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  82. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  83. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  84. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  85. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  86. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  87. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  88. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  89. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  90. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  91. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  92. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  93. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  94. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  95. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  96. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/stepdown.py +0 -0
  97. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  98. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  99. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  100. {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.11}/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.10
3
+ Version: 0.67.11
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.10"
3
+ version = "0.67.11"
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" }]
@@ -108,6 +108,7 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
108
108
  )
109
109
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
110
110
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
111
+ from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
111
112
  from sarj_python_lint.rules.stepdown import Stepdown
112
113
  from sarj_python_lint.rules.store_insert_requires_on_conflict import (
113
114
  StoreInsertRequiresOnConflict,
@@ -140,6 +141,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
140
141
  PreferStructOverNamedtuple.id: PreferStructOverNamedtuple,
141
142
  NoCommentCruft.id: NoCommentCruft,
142
143
  StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
144
+ SourceCoupledTest.id: SourceCoupledTest,
143
145
  NoQueryWithManyJoins.id: NoQueryWithManyJoins,
144
146
  NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
145
147
  NoSelectStar.id: NoSelectStar,
@@ -0,0 +1,357 @@
1
+ """SARJ402 — tests must not use raw repository source text as behavioral proof.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_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
+
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+
29
+
30
+ _SOURCE_SUFFIXES = (
31
+ ".bash",
32
+ ".hcl",
33
+ ".js",
34
+ ".mjs",
35
+ ".py",
36
+ ".sh",
37
+ ".tf",
38
+ ".tfvars",
39
+ ".ts",
40
+ ".yaml",
41
+ ".yml",
42
+ )
43
+ _TEXT_TRANSFORMS = frozenset(
44
+ {"casefold", "lower", "lstrip", "removeprefix", "removesuffix", "replace", "rstrip", "strip", "upper"}
45
+ )
46
+ _TEXT_ASSERTIONS = frozenset({"count", "endswith", "find", "index", "startswith"})
47
+ _REGEX_ASSERTIONS = frozenset({"findall", "finditer", "fullmatch", "match", "search"})
48
+ _TEMP_PATH_NAMES = frozenset({"tmp_path", "tmpdir", "temp_dir", "temporary_directory"})
49
+
50
+
51
+ @final
52
+ class SourceCoupledTest(Rule):
53
+ id = "source-coupled-test"
54
+ code = "SARJ402"
55
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
56
+ summary="Test asserts on raw repository source text instead of parsed or executable behavior.",
57
+ 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.",
59
+ category=RuleCategory.TESTING,
60
+ limitations=(
61
+ "The rule follows local aliases, path aliases, context-managed reads, and common text normalization; interprocedural flows remain unreported.",
62
+ "Files produced beneath recognized temporary-directory fixtures are generated artifacts, not repository source, and remain unreported.",
63
+ "When raw representation is genuinely the contract (for example a golden or compatibility sentinel), use an exact line suppression with the reason.",
64
+ ),
65
+ examples=(
66
+ RuleExample(
67
+ example_id="parsed-terraform-contract",
68
+ title="Assert on parsed plan behavior",
69
+ outcome=ExampleOutcome.NO_MATCH,
70
+ files=(
71
+ ExampleFile.python(
72
+ "tests/test_policy.py",
73
+ "def test_policy():\n plan = json.loads(Path('plan.json').read_text())\n assert verify(plan) == []\n",
74
+ ),
75
+ ),
76
+ focus_path=PurePosixPath("tests/test_policy.py"),
77
+ expected_count=0,
78
+ public=True,
79
+ ),
80
+ RuleExample(
81
+ example_id="terraform-substring-contract",
82
+ title="Do not prove Terraform behavior with a substring",
83
+ outcome=ExampleOutcome.MATCH,
84
+ files=(
85
+ ExampleFile.python(
86
+ "tests/test_policy.py",
87
+ "def test_policy():\n source = Path('main.tf').read_text()\n assert 'prevent_destroy = true' in source\n",
88
+ ),
89
+ ),
90
+ focus_path=PurePosixPath("tests/test_policy.py"),
91
+ expected_count=1,
92
+ public=True,
93
+ ),
94
+ ),
95
+ )
96
+ description = documentation.summary
97
+
98
+ @override
99
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
100
+ if not is_test_path(path) or is_generated(path, source):
101
+ return []
102
+ tree = parse_or_none(path, source)
103
+ if not isinstance(tree, ast.Module):
104
+ return []
105
+ assertions = [
106
+ assertion
107
+ for function in _top_level_test_functions(tree)
108
+ for assertion in _FunctionAnalyzer().analyze(function)
109
+ ]
110
+ return [
111
+ Diagnostic(
112
+ path=path,
113
+ line=assertion.lineno,
114
+ col=assertion.col_offset + 1,
115
+ code=self.code,
116
+ severity=Severity.WARNING,
117
+ message=(
118
+ "raw repository source text is the test oracle; parse or execute the artifact so comments, formatting, and unreachable blocks cannot satisfy the contract."
119
+ ),
120
+ )
121
+ for assertion in assertions
122
+ ]
123
+
124
+
125
+ def _top_level_test_functions(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
126
+ functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
127
+ for statement in tree.body:
128
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) and statement.name.startswith("test"):
129
+ functions.append(statement)
130
+ elif isinstance(statement, ast.ClassDef):
131
+ functions.extend(
132
+ child
133
+ for child in statement.body
134
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test")
135
+ )
136
+ return functions
137
+
138
+
139
+ class _FunctionAnalyzer(ast.NodeVisitor):
140
+ """Track raw text inside one test without leaking into nested lexical scopes."""
141
+
142
+ def __init__(self) -> None:
143
+ self._collections: set[str] = set()
144
+ self._ephemeral_paths: set[str] = set()
145
+ self._paths: set[str] = set()
146
+ self._raw: set[str] = set()
147
+ self._raw_origins: dict[str, set[int]] = {}
148
+ self._reported_origins: set[int] = set()
149
+ self._assertions: list[ast.Assert] = []
150
+
151
+ def analyze(self, function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.Assert]:
152
+ arguments = [*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs]
153
+ self._ephemeral_paths.update(argument.arg for argument in arguments if argument.arg in _TEMP_PATH_NAMES)
154
+ for statement in function.body:
155
+ self.visit(statement)
156
+ return self._assertions
157
+
158
+ @override
159
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
160
+ del node # A nested helper owns a separate scope and is intentionally not inferred.
161
+
162
+ @override
163
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
164
+ del node
165
+
166
+ @override
167
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
168
+ del node
169
+
170
+ @override
171
+ def visit_Lambda(self, node: ast.Lambda) -> None:
172
+ del node
173
+
174
+ @override
175
+ def visit_Assign(self, node: ast.Assign) -> None:
176
+ self.visit(node.value)
177
+ for target in node.targets:
178
+ self._record_target(target, node.value)
179
+
180
+ @override
181
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
182
+ if node.value is None:
183
+ return
184
+ self.visit(node.value)
185
+ self._record_target(node.target, node.value)
186
+
187
+ @override
188
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
189
+ self.visit(node.value)
190
+ self._record_target(node.target, node.value)
191
+
192
+ @override
193
+ def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
194
+ for item in node.items:
195
+ self.visit(item.context_expr)
196
+ if isinstance(item.optional_vars, ast.Name) and _is_source_open(
197
+ item.context_expr, self._paths, self._ephemeral_paths
198
+ ):
199
+ self._raw.add(item.optional_vars.id)
200
+ self._raw_origins[item.optional_vars.id] = {item.context_expr.lineno}
201
+ for statement in node.body:
202
+ self.visit(statement)
203
+
204
+ @override
205
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
206
+ self.visit_With(node)
207
+
208
+ @override
209
+ 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
+ }
214
+ if not origins.issubset(self._reported_origins):
215
+ self._assertions.append(node)
216
+ self._reported_origins.update(origins)
217
+ self.generic_visit(node)
218
+
219
+ @override
220
+ def visit_For(self, node: ast.For) -> None:
221
+ self._visit_for(node)
222
+
223
+ @override
224
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
225
+ self._visit_for(node)
226
+
227
+ def _visit_for(self, node: ast.For | ast.AsyncFor) -> None:
228
+ self.visit(node.iter)
229
+ if isinstance(node.target, ast.Name) and isinstance(node.iter, ast.Name) and node.iter.id in self._collections:
230
+ self._paths.add(node.target.id)
231
+ for statement in [*node.body, *node.orelse]:
232
+ self.visit(statement)
233
+
234
+ def _record_target(self, target: ast.expr, value: ast.expr) -> None:
235
+ if not isinstance(target, ast.Name):
236
+ return
237
+ raw_origins = _expression_origins(value, self._raw_origins, self._paths, self._ephemeral_paths)
238
+ # Reassignment kills stale taint before adding the new value's state.
239
+ self._paths.discard(target.id)
240
+ self._raw.discard(target.id)
241
+ self._raw_origins.pop(target.id, None)
242
+ self._collections.discard(target.id)
243
+ self._ephemeral_paths.discard(target.id)
244
+ if _ephemeral_path_expression(value, self._ephemeral_paths):
245
+ self._ephemeral_paths.add(target.id)
246
+ if _source_path_collection(value, self._paths):
247
+ self._collections.add(target.id)
248
+ if _source_path_expression(value, self._paths):
249
+ self._paths.add(target.id)
250
+ if _raw_text_expression(value, self._raw, self._paths, self._ephemeral_paths):
251
+ self._raw.add(target.id)
252
+ self._raw_origins[target.id] = raw_origins or {value.lineno}
253
+
254
+
255
+ def _is_source_open(node: ast.expr, path_names: set[str], ephemeral_path_names: set[str]) -> bool:
256
+ return (
257
+ isinstance(node, ast.Call)
258
+ and isinstance(node.func, ast.Name)
259
+ and node.func.id == "open"
260
+ and bool(node.args)
261
+ and _source_path_expression(node.args[0], path_names)
262
+ and not _ephemeral_path_expression(node.args[0], ephemeral_path_names)
263
+ )
264
+
265
+
266
+ def _source_path_expression(node: ast.AST, path_names: set[str]) -> bool:
267
+ match node:
268
+ case ast.Name(id=name):
269
+ return name in path_names
270
+ case ast.Constant(value=str(value)):
271
+ return value.lower().endswith(_SOURCE_SUFFIXES)
272
+ case ast.JoinedStr(values=values):
273
+ return any(_source_path_expression(value, path_names) for value in values)
274
+ 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))
276
+ case _:
277
+ return False
278
+
279
+
280
+ def _ephemeral_path_expression(node: ast.AST, ephemeral_path_names: set[str]) -> bool:
281
+ return any(isinstance(child, ast.Name) and child.id in ephemeral_path_names for child in ast.walk(node))
282
+
283
+
284
+ def _source_path_collection(node: ast.AST, path_names: set[str]) -> bool:
285
+ return (
286
+ isinstance(node, (ast.List, ast.Set, ast.Tuple))
287
+ and bool(node.elts)
288
+ and all(_source_path_expression(element, path_names) for element in node.elts)
289
+ )
290
+
291
+
292
+ def _raw_source_read(node: ast.expr, path_names: set[str], ephemeral_path_names: set[str]) -> bool:
293
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
294
+ return False
295
+ if node.func.attr == "read_text":
296
+ return _source_path_expression(node.func.value, path_names) and not _ephemeral_path_expression(
297
+ node.func.value, ephemeral_path_names
298
+ )
299
+ return node.func.attr == "read" and _is_source_open(node.func.value, path_names, ephemeral_path_names)
300
+
301
+
302
+ def _raw_text_expression(
303
+ node: ast.expr, raw_names: set[str], path_names: set[str], ephemeral_path_names: set[str]
304
+ ) -> bool:
305
+ if isinstance(node, ast.Name):
306
+ return node.id in raw_names
307
+ if _raw_source_read(node, path_names, ephemeral_path_names):
308
+ return True
309
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
310
+ if node.func.attr == "read" and isinstance(node.func.value, ast.Name):
311
+ return node.func.value.id in raw_names
312
+ return node.func.attr in _TEXT_TRANSFORMS and _raw_text_expression(
313
+ node.func.value, raw_names, path_names, ephemeral_path_names
314
+ )
315
+ 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
+ )
319
+ return False
320
+
321
+
322
+ def _raw_text_oracle(node: ast.expr, raw_names: set[str], path_names: set[str], ephemeral_path_names: set[str]) -> bool:
323
+ if isinstance(node, ast.Compare):
324
+ operands = [node.left, *node.comparators]
325
+ if any(
326
+ _raw_text_expression(operand, raw_names, path_names, ephemeral_path_names) for operand in operands
327
+ ) and any(isinstance(operator, (ast.In, ast.NotIn, ast.Eq, ast.NotEq)) for operator in node.ops):
328
+ return True
329
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
330
+ if node.func.attr in _TEXT_ASSERTIONS and _raw_text_expression(
331
+ node.func.value, raw_names, path_names, ephemeral_path_names
332
+ ):
333
+ return True
334
+ 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
336
+ ):
337
+ return True
338
+ return any(
339
+ _raw_text_oracle(child, raw_names, path_names, ephemeral_path_names)
340
+ for child in ast.iter_child_nodes(node)
341
+ if isinstance(child, ast.expr)
342
+ )
343
+
344
+
345
+ def _expression_origins(
346
+ node: ast.AST,
347
+ raw_origins: dict[str, set[int]],
348
+ path_names: set[str],
349
+ ephemeral_path_names: set[str],
350
+ ) -> set[int]:
351
+ origins: set[int] = set()
352
+ for child in ast.walk(node):
353
+ if isinstance(child, ast.Name):
354
+ origins.update(raw_origins.get(child.id, set()))
355
+ elif isinstance(child, ast.Call) and _raw_source_read(child, path_names, ephemeral_path_names):
356
+ origins.add(child.lineno)
357
+ return origins