claude-dev-env 1.50.0 → 1.50.2

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 (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. package/hooks/blocking/test_md_to_html_blocker.py +0 -810
@@ -0,0 +1,238 @@
1
+ """Behavior tests for the code_rules_enforcer code-rules check module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+
11
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
12
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
13
+ if _BLOCKING_DIRECTORY not in sys.path:
14
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
15
+ if _HOOKS_DIRECTORY not in sys.path:
16
+ sys.path.insert(0, _HOOKS_DIRECTORY)
17
+
18
+ from code_rules_annotations_length import ( # noqa: E402
19
+ FUNCTION_LENGTH_BLOCKING_THRESHOLD,
20
+ )
21
+ from code_rules_enforcer import ( # noqa: E402
22
+ main,
23
+ prior_and_post_edit_content,
24
+ validate_content,
25
+ )
26
+
27
+ code_rules_enforcer = SimpleNamespace(
28
+ FUNCTION_LENGTH_BLOCKING_THRESHOLD=FUNCTION_LENGTH_BLOCKING_THRESHOLD,
29
+ main=main,
30
+ prior_and_post_edit_content=prior_and_post_edit_content,
31
+ sys=sys,
32
+ validate_content=validate_content,
33
+ )
34
+
35
+
36
+ def _oversized_function_source(name: str) -> str:
37
+ body_line_count = code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
38
+ body_lines = [
39
+ f" bound_{each_index} = {each_index}" for each_index in range(body_line_count)
40
+ ]
41
+ return f"def {name}() -> None:\n" + "\n".join(body_lines) + "\n"
42
+
43
+
44
+ def _run_main_with_edit_payload(
45
+ file_path: str,
46
+ old_string: str,
47
+ new_string: str,
48
+ monkeypatch: object,
49
+ capsys: object,
50
+ ) -> str:
51
+ """Drive ``main()`` through its stdin entry point for an Edit and return stdout.
52
+
53
+ Args:
54
+ file_path: The on-disk path the Edit targets.
55
+ old_string: The Edit's ``old_string`` fragment.
56
+ new_string: The Edit's ``new_string`` fragment.
57
+ monkeypatch: The pytest fixture used to redirect ``sys.stdin``.
58
+ capsys: The pytest fixture used to capture the deny payload on stdout.
59
+
60
+ Returns:
61
+ The captured stdout, which holds the deny payload when violations fire.
62
+ """
63
+ edit_payload = json.dumps(
64
+ {
65
+ "tool_name": "Edit",
66
+ "tool_input": {
67
+ "file_path": file_path,
68
+ "old_string": old_string,
69
+ "new_string": new_string,
70
+ },
71
+ }
72
+ )
73
+ getattr(monkeypatch, "setattr")(code_rules_enforcer.sys, "stdin", io.StringIO(edit_payload))
74
+ try:
75
+ code_rules_enforcer.main()
76
+ except SystemExit:
77
+ pass
78
+ captured = getattr(capsys, "readouterr")()
79
+ return captured.out
80
+
81
+
82
+ def test_banned_noun_word_keeps_in_scope_binding_among_untouched_ones() -> None:
83
+ """loop7-P1: an Edit whose changed line introduces a banned-noun identifier
84
+ among several pre-existing untouched ones must still report the new in-scope
85
+ binding while leaving the untouched bindings out of scope."""
86
+ leading_count = 5
87
+ leading_bindings = "".join(
88
+ f"LEADING_{each_index}_RESULT_PATH = {each_index}\n"
89
+ for each_index in range(leading_count)
90
+ )
91
+ target_before = "PLACEHOLDER_NAME = 0\n"
92
+ target_after = "INTRODUCED_RESULT_PATH = 0\n"
93
+ prior_full_file = leading_bindings + target_before
94
+ post_edit_full_file = leading_bindings + target_after
95
+ issues = code_rules_enforcer.validate_content(
96
+ target_after,
97
+ "/project/src/many_nouns.py",
98
+ old_content=target_before,
99
+ full_file_content=post_edit_full_file,
100
+ prior_full_file_content=prior_full_file,
101
+ )
102
+ assert any(
103
+ "INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
104
+ ), f"in-scope banned-noun past the cap window must still block, got: {issues!r}"
105
+
106
+
107
+ def test_banned_noun_edit_drops_untouched_out_of_scope_binding() -> None:
108
+ """An Edit that touches none of the banned-noun bindings reports nothing —
109
+ the check now routes through the reconstructed effective content and the
110
+ edit's changed lines, exactly like check_function_length, so an untouched
111
+ binding outside the edit hunk must not block."""
112
+ leading = "".join(
113
+ f"LEADING_{each_index}_RESULT_PATH = {each_index}\n" for each_index in range(5)
114
+ )
115
+ edited_tail = "def compute_total() -> int:\n running_sum = 0\n return running_sum\n"
116
+ prior_full_file = leading + "def compute_total() -> int:\n running_sum = 0\n return 0\n"
117
+ post_edit_full_file = leading + edited_tail
118
+ issues = code_rules_enforcer.validate_content(
119
+ edited_tail,
120
+ "/project/src/many_nouns.py",
121
+ old_content="def compute_total() -> int:\n running_sum = 0\n return 0\n",
122
+ full_file_content=post_edit_full_file,
123
+ prior_full_file_content=prior_full_file,
124
+ )
125
+ assert not any(
126
+ "RESULT_PATH" in each_issue for each_issue in issues
127
+ ), f"untouched banned-noun bindings must stay out of scope, got: {issues!r}"
128
+
129
+
130
+ def test_banned_noun_edit_keeps_touched_binding_in_scope() -> None:
131
+ """An Edit whose changed line introduces a banned-noun binding reports it,
132
+ using the reconstructed effective content and the edit's changed lines."""
133
+ leading = "".join(
134
+ f"LEADING_{each_index}_VALUE_PATH = {each_index}\n" for each_index in range(5)
135
+ )
136
+ prior_tail = "PLACEHOLDER_NAME = 0\n"
137
+ edited_tail = "INTRODUCED_RESULT_PATH = 0\n"
138
+ prior_full_file = leading + prior_tail
139
+ post_edit_full_file = leading + edited_tail
140
+ issues = code_rules_enforcer.validate_content(
141
+ edited_tail,
142
+ "/project/src/introduces_noun.py",
143
+ old_content=prior_tail,
144
+ full_file_content=post_edit_full_file,
145
+ prior_full_file_content=prior_full_file,
146
+ )
147
+ assert any(
148
+ "INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
149
+ ), f"introduced banned-noun binding must block, got: {issues!r}"
150
+
151
+
152
+ def test_banned_noun_edit_does_not_reflag_param_when_unrelated_body_line_changes() -> None:
153
+ """Editing a body line of a function that already has a banned-noun
154
+ parameter must not re-flag that pre-existing parameter: the binding-line
155
+ span keeps the parameter out of scope unless its own declaration line is in
156
+ the changed set."""
157
+ prior_full_file = (
158
+ "def transform(canned_results: int) -> int:\n"
159
+ " midpoint = canned_results\n"
160
+ " return midpoint\n"
161
+ )
162
+ post_edit_full_file = (
163
+ "def transform(canned_results: int) -> int:\n"
164
+ " midpoint = canned_results + 1\n"
165
+ " return midpoint\n"
166
+ )
167
+ issues = code_rules_enforcer.validate_content(
168
+ " midpoint = canned_results + 1\n",
169
+ "/project/src/has_param.py",
170
+ old_content=" midpoint = canned_results\n",
171
+ full_file_content=post_edit_full_file,
172
+ prior_full_file_content=prior_full_file,
173
+ )
174
+ assert not any(
175
+ "canned_results" in each_issue for each_issue in issues
176
+ ), f"pre-existing param must not re-flag on unrelated body edit, got: {issues!r}"
177
+
178
+
179
+ def test_unreadable_prior_yields_no_prior_and_no_reconstruction() -> None:
180
+ """When the on-disk prior cannot be read for an Edit, the prior/post helper
181
+ returns (None, None): a missing prior must not be fabricated as an empty
182
+ string that would diff every line as changed and defeat edit scoping."""
183
+ missing_path = "/project/src/does_not_exist_anywhere.py"
184
+ prior_content, post_edit_content = code_rules_enforcer.prior_and_post_edit_content(
185
+ missing_path,
186
+ old_string="placeholder = 0\n",
187
+ new_string="placeholder = 1\n",
188
+ )
189
+ assert prior_content is None
190
+ assert post_edit_content is None
191
+
192
+
193
+ def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
194
+ tmp_path_factory: object, monkeypatch: object, capsys: object,
195
+ ) -> None:
196
+ """When an Edit's old_string is absent from the file, ``prior_and_post_edit_content``
197
+ yields ``(None, None)``; ``main()`` must analyze the real on-disk file whole-file
198
+ rather than the new_string fragment, so an oversized function elsewhere in the
199
+ file is still reported with its true line numbers."""
200
+ production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
201
+ untouched_long_function = _oversized_function_source("untouched_long")
202
+ short_helper = "def short_helper() -> int:\n return 1\n"
203
+ on_disk_content = untouched_long_function + "\n" + short_helper
204
+ source_file = production_directory / "edited_module.py"
205
+ source_file.write_text(on_disk_content, encoding="utf-8")
206
+ absent_fragment_old = "def absent_function() -> int:\n return 0\n"
207
+ short_fragment_new = "def absent_function() -> int:\n return 2\n"
208
+ stdout = _run_main_with_edit_payload(
209
+ str(source_file), absent_fragment_old, short_fragment_new, monkeypatch, capsys,
210
+ )
211
+ assert "untouched_long" in stdout, (
212
+ "an unreconstructable Edit must fall back to whole-file on-disk analysis, "
213
+ f"so the oversized function is still reported; got stdout: {stdout!r}"
214
+ )
215
+
216
+
217
+ def test_edit_with_unreadable_file_does_not_analyze_fragment_as_whole_file(
218
+ tmp_path_factory: object, monkeypatch: object, capsys: object,
219
+ ) -> None:
220
+ """When the on-disk file cannot be read, no well-defined post-edit content
221
+ exists; ``main()`` must exit cleanly rather than analyze the new_string
222
+ fragment as if it were the whole file, so the fragment's own function-length
223
+ violation does not surface as a deny payload."""
224
+ production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
225
+ missing_path = str(production_directory / "never_created.py")
226
+ oversized_fragment_old = "def grows() -> int:\n return 0\n"
227
+ oversized_fragment_new = _oversized_function_source("grows")
228
+ stdout = _run_main_with_edit_payload(
229
+ missing_path,
230
+ oversized_fragment_old,
231
+ oversized_fragment_new,
232
+ monkeypatch,
233
+ capsys,
234
+ )
235
+ assert stdout == "", (
236
+ "an unreadable Edit target has no well-defined whole-file content, so the "
237
+ f"fragment must not be analyzed as the whole file; got stdout: {stdout!r}"
238
+ )
@@ -0,0 +1,271 @@
1
+ """Behavior tests for the code_rules_test_isolation code-rules check module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+
9
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
10
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
11
+ if _BLOCKING_DIRECTORY not in sys.path:
12
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
13
+ if _HOOKS_DIRECTORY not in sys.path:
14
+ sys.path.insert(0, _HOOKS_DIRECTORY)
15
+
16
+ from code_rules_test_isolation import ( # noqa: E402
17
+ check_tests_use_isolated_filesystem_paths,
18
+ )
19
+
20
+ code_rules_enforcer = SimpleNamespace(
21
+ check_tests_use_isolated_filesystem_paths=check_tests_use_isolated_filesystem_paths,
22
+ )
23
+
24
+
25
+ def test_isolation_check_does_not_flag_expanduser_without_tilde_argument() -> None:
26
+ """expanduser of a tilde-free string does not probe HOME and must not fire."""
27
+ source = (
28
+ "import os\n"
29
+ "def test_resolves_relative() -> None:\n"
30
+ " target = os.path.expanduser('relative/path')\n"
31
+ " assert target\n"
32
+ )
33
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
34
+ source, "/project/src/test_module.py"
35
+ )
36
+ assert issues == [], f"tilde-free expanduser must not be flagged, got: {issues!r}"
37
+
38
+
39
+ def test_isolation_check_flags_expanduser_with_tilde_argument() -> None:
40
+ """expanduser of a leading-tilde string resolves HOME and must fire."""
41
+ source = (
42
+ "import os\n"
43
+ "def test_reads_home() -> None:\n"
44
+ " target = os.path.expanduser('~/.config/x')\n"
45
+ " assert target\n"
46
+ )
47
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
48
+ source, "/project/src/test_module.py"
49
+ )
50
+ assert any("expanduser" in each_issue for each_issue in issues)
51
+
52
+
53
+ def test_isolation_check_flags_path_constructor_expanduser_method() -> None:
54
+ """`Path('~/x').expanduser()` expands the home directory through the bound
55
+ Path object and must fire even though it bypasses the static probe chain."""
56
+ source = (
57
+ "from pathlib import Path\n"
58
+ "def test_reads_dotfile() -> None:\n"
59
+ " target = Path('~/x').expanduser()\n"
60
+ " target.read_text()\n"
61
+ )
62
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
63
+ source, "/project/src/test_module.py"
64
+ )
65
+ assert any("expanduser" in each_issue for each_issue in issues)
66
+
67
+
68
+ def test_isolation_check_flags_aliased_path_constructor_expanduser_method() -> None:
69
+ """`from pathlib import Path as P` then `P('~/x').expanduser()` resolves the
70
+ constructor through alias canonicalization and must fire."""
71
+ source = (
72
+ "from pathlib import Path as P\n"
73
+ "def test_reads_dotfile() -> None:\n"
74
+ " target = P('~/x').expanduser()\n"
75
+ " target.read_text()\n"
76
+ )
77
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
78
+ source, "/project/src/test_module.py"
79
+ )
80
+ assert any("expanduser" in each_issue for each_issue in issues)
81
+
82
+
83
+ def test_isolation_check_flags_tempfile_named_temporary_file() -> None:
84
+ """`tempfile.NamedTemporaryFile()` allocates in the shared temp dir and must
85
+ fire as a temp-isolation probe."""
86
+ source = (
87
+ "import tempfile\n"
88
+ "def test_writes_named_temp() -> None:\n"
89
+ " handle = tempfile.NamedTemporaryFile()\n"
90
+ " handle.write(b'x')\n"
91
+ )
92
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
93
+ source, "/project/src/test_module.py"
94
+ )
95
+ assert any("NamedTemporaryFile" in each_issue for each_issue in issues)
96
+
97
+
98
+ def test_isolation_check_exempts_tempfile_factory_with_explicit_dir() -> None:
99
+ """A tempfile factory given an explicit `dir=` argument allocates under the
100
+ supplied sandbox, so it must not fire as a shared-temp isolation probe."""
101
+ source = (
102
+ "import tempfile\n"
103
+ "def test_writes_named_temp(tmp_path) -> None:\n"
104
+ " handle = tempfile.NamedTemporaryFile(dir=tmp_path)\n"
105
+ " handle.write(b'x')\n"
106
+ )
107
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
108
+ source, "/project/src/test_module.py"
109
+ )
110
+ assert issues == []
111
+
112
+
113
+ def test_isolation_check_flags_tempfile_factory_with_dir_constant_none() -> None:
114
+ """`dir=None` selects the default shared temp directory, so the factory
115
+ still allocates from shared temp and must fire."""
116
+ source = (
117
+ "import tempfile\n"
118
+ "def test_writes_named_temp() -> None:\n"
119
+ " handle = tempfile.NamedTemporaryFile(dir=None)\n"
120
+ " handle.write(b'x')\n"
121
+ )
122
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
123
+ source, "/project/src/test_module.py"
124
+ )
125
+ assert any("NamedTemporaryFile" in each_issue for each_issue in issues)
126
+
127
+
128
+ def test_isolation_check_flags_tempfile_factory_with_dir_getenv_tmpdir() -> None:
129
+ """`dir=os.getenv('TMPDIR')` resolves to a shared-temp env source, so the
130
+ factory still allocates from shared temp and must fire."""
131
+ source = (
132
+ "import os\n"
133
+ "import tempfile\n"
134
+ "def test_makes_temp_dir() -> None:\n"
135
+ " holder = tempfile.mkdtemp(dir=os.getenv('TMPDIR'))\n"
136
+ " print(holder)\n"
137
+ )
138
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
139
+ source, "/project/src/test_module.py"
140
+ )
141
+ assert any("mkdtemp" in each_issue for each_issue in issues)
142
+
143
+
144
+ def test_isolation_check_exempts_tempfile_factory_with_dir_tmp_path() -> None:
145
+ """`dir=tmp_path` allocates under the pytest sandbox, so the factory is
146
+ isolated and must not fire."""
147
+ source = (
148
+ "import tempfile\n"
149
+ "def test_makes_temp_dir(tmp_path) -> None:\n"
150
+ " holder = tempfile.mkdtemp(dir=tmp_path)\n"
151
+ " print(holder)\n"
152
+ )
153
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
154
+ source, "/project/src/test_module.py"
155
+ )
156
+ assert issues == []
157
+
158
+
159
+ def test_isolation_check_flags_class_level_probe_in_nested_class_body() -> None:
160
+ """A Path.home() initializer in a nested class body runs at class-creation
161
+ time during the test, so it must fire."""
162
+ source = (
163
+ "from pathlib import Path\n"
164
+ "def test_defines_inner_class() -> None:\n"
165
+ " class Inner:\n"
166
+ " root = Path.home()\n"
167
+ " assert Inner is not None\n"
168
+ )
169
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
170
+ source, "/project/src/test_module.py"
171
+ )
172
+ assert any("Path.home" in each_issue for each_issue in issues)
173
+
174
+
175
+ def test_isolation_check_flags_from_os_import_path_expanduser() -> None:
176
+ """`from os import path` binds `path` to `os.path`, so `path.expanduser`
177
+ must resolve to the canonical `os.path.expanduser` probe and fire."""
178
+ source = (
179
+ "from os import path\n"
180
+ "def test_reads_dotfile() -> None:\n"
181
+ " target = path.expanduser('~/.config/x')\n"
182
+ " open(target).read()\n"
183
+ )
184
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
185
+ source, "/project/src/test_module.py"
186
+ )
187
+ assert any("expanduser" in each_issue for each_issue in issues)
188
+
189
+
190
+ def test_isolation_check_flags_expandvars_with_windows_percent_userprofile() -> None:
191
+ """expandvars expands Windows `%USERPROFILE%` percent syntax, so a percent
192
+ reference to a home env var must fire."""
193
+ source = (
194
+ "import os\n"
195
+ "def test_expands_userprofile() -> None:\n"
196
+ " target = os.path.expandvars('%USERPROFILE%\\\\.cfg')\n"
197
+ " open(target).read()\n"
198
+ )
199
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
200
+ source, "/project/src/test_module.py"
201
+ )
202
+ assert any("expandvars" in each_issue for each_issue in issues)
203
+
204
+
205
+ def test_isolation_check_ignores_expandvars_with_unrelated_windows_percent_var() -> None:
206
+ """A percent reference to an unrelated env var does not probe HOME/TMP and
207
+ must not fire."""
208
+ source = (
209
+ "import os\n"
210
+ "def test_expands_unrelated() -> None:\n"
211
+ " token = os.path.expandvars('%MY_APP_TOKEN%')\n"
212
+ " print(token)\n"
213
+ )
214
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
215
+ source, "/project/src/test_module.py"
216
+ )
217
+ assert issues == []
218
+
219
+
220
+ def test_isolation_check_flags_environ_get_via_local_binding() -> None:
221
+ """`e = os.environ` then `e.get('HOME')` reads HOME through a local alias
222
+ and must fire just like the subscript `e['HOME']` form."""
223
+ source = (
224
+ "import os\n"
225
+ "def test_resolves_home() -> None:\n"
226
+ " e = os.environ\n"
227
+ " home = e.get('HOME')\n"
228
+ " print(home)\n"
229
+ )
230
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
231
+ source, "/project/src/test_module.py"
232
+ )
233
+ assert any("HOME" in each_issue for each_issue in issues)
234
+
235
+
236
+ def test_isolation_check_scopes_path_bindings_to_their_own_test() -> None:
237
+ """A `p = Path('~/x')` binding in one test must not make an unrelated
238
+ `p.expanduser()` in a sibling test a finding; bindings are per-test."""
239
+ source = (
240
+ "from pathlib import Path\n"
241
+ "def test_a() -> None:\n"
242
+ " p = Path('~/x')\n"
243
+ " p.expanduser()\n"
244
+ "def test_b(p) -> None:\n"
245
+ " p.expanduser()\n"
246
+ )
247
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
248
+ source, "/project/src/test_module.py"
249
+ )
250
+ assert any("test_a" in each_issue for each_issue in issues)
251
+ assert not any("test_b" in each_issue for each_issue in issues)
252
+
253
+
254
+ def test_isolation_check_scopes_environ_bindings_to_their_own_test() -> None:
255
+ """An `e = os.environ` binding in one test must not make an unrelated
256
+ `e['HOME']` in a sibling test a finding; bindings are per-test."""
257
+ source = (
258
+ "import os\n"
259
+ "def test_a() -> None:\n"
260
+ " e = os.environ\n"
261
+ " home = e['HOME']\n"
262
+ " print(home)\n"
263
+ "def test_b(e) -> None:\n"
264
+ " home = e['HOME']\n"
265
+ " print(home)\n"
266
+ )
267
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
268
+ source, "/project/src/test_module.py"
269
+ )
270
+ assert any("test_a" in each_issue for each_issue in issues)
271
+ assert not any("test_b" in each_issue for each_issue in issues)