claude-dev-env 2.31.0 → 2.32.0

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 (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. package/scripts/test_shared_tree_paths.py +173 -0
@@ -8,8 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  from pathlib import Path
10
10
 
11
- import pytest
12
-
13
11
  from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
14
12
  from hooks_constants.code_rules_path_utils_constants import ALL_CONFIG_DIRECTORY_NAMES
15
13
  from validators.config.directory_exemption_constants import (
@@ -19,6 +17,7 @@ from validators.config.directory_exemption_constants import (
19
17
  all_directory_segments_in_path_pattern,
20
18
  directory_segment_names_from_path_patterns,
21
19
  is_filename_like_path_segment,
20
+ is_pytest_scratch_directory_segment,
22
21
  substring_patterns_from_path_patterns,
23
22
  )
24
23
  from validators.exempt_paths import (
@@ -36,6 +35,9 @@ ALL_SYNTHETIC_DIRECTORY_PATTERNS = frozenset(
36
35
  {"/foo/", "\\bar\\", "/baz.py", "plain", "/.hidden/qux/"}
37
36
  )
38
37
  ALL_EXPECTED_SYNTHETIC_SEGMENTS = frozenset({"foo", "bar", ".hidden", "qux"})
38
+ OVER_LONG_STEM_DIRECTORY_NAME = "test_helpers_for_a_very_long_domain_name0"
39
+ PROJECT_ROOTED_BASETEMP_PATH = "/srv/projects/scratch-basetemp"
40
+ PYTEST_SCRATCH_DIRECTORY_NAME = "test_edit_introducing_new_viol0"
39
41
 
40
42
 
41
43
  def test_is_filename_like_path_segment_detects_extensions() -> None:
@@ -45,6 +47,21 @@ def test_is_filename_like_path_segment_detects_extensions() -> None:
45
47
  assert is_filename_like_path_segment(".claude") is False
46
48
 
47
49
 
50
+ def test_is_pytest_scratch_directory_segment_detects_numbered_test_directories() -> None:
51
+ assert is_pytest_scratch_directory_segment("test_drops_leading_anchor0") is True
52
+ assert is_pytest_scratch_directory_segment("test_edit_introducing_new_viol0") is True
53
+ assert is_pytest_scratch_directory_segment("pytest-of-root") is True
54
+ assert is_pytest_scratch_directory_segment("pytest-9") is True
55
+
56
+
57
+ def test_is_pytest_scratch_directory_segment_keeps_real_project_directories() -> None:
58
+ assert is_pytest_scratch_directory_segment("test_helpers") is False
59
+ assert is_pytest_scratch_directory_segment("tests") is False
60
+ assert is_pytest_scratch_directory_segment("scripts") is False
61
+ assert is_pytest_scratch_directory_segment("conftest") is False
62
+ assert is_pytest_scratch_directory_segment(OVER_LONG_STEM_DIRECTORY_NAME) is False
63
+
64
+
48
65
  def test_all_directory_segments_in_path_pattern_keeps_directory_tokens_only() -> None:
49
66
  assert all_directory_segments_in_path_pattern("/.claude/hooks/") == [
50
67
  ".claude",
@@ -136,32 +153,21 @@ def test_absolute_system_temp_pytest_shaped_path_stages_flat_basename(
136
153
  assert staged_path == staging_root / "legacy_module.py"
137
154
 
138
155
 
139
- def test_runner_temp_pytest_shaped_path_stages_flat_basename_when_gettempdir_differs(
156
+ def test_pytest_shaped_path_stages_flat_basename_under_every_basetemp_placement(
140
157
  tmp_path: Path,
141
- monkeypatch: pytest.MonkeyPatch,
142
158
  ) -> None:
143
- """GHA basetemp lives under RUNNER_TEMP while gettempdir is often /tmp."""
144
- runner_temp_root = tmp_path / "runner_temp"
145
- os_gettemp = tmp_path / "os_gettemp"
146
- runner_temp_root.mkdir()
147
- os_gettemp.mkdir()
148
- monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
149
- monkeypatch.setattr(
150
- "validators.system_temporary_roots.tempfile.gettempdir",
151
- lambda: str(os_gettemp),
152
- )
153
- pytest_shaped_target = (
154
- runner_temp_root
155
- / "pytest-basetemp-pkg"
156
- / "test_edit_introducing_new_viol0"
157
- / "legacy_module.py"
158
- )
159
+ """A ``--basetemp`` outside every temp root must not change the staged path."""
159
160
  staging_root = tmp_path / "staging_root"
160
161
  staging_root.mkdir()
161
- staged_path = _temporary_path_preserving_directory_signal(
162
- staging_root, str(pytest_shaped_target)
163
- )
164
- assert staged_path == staging_root / "legacy_module.py"
162
+ all_basetemp_roots = (tmp_path, Path(PROJECT_ROOTED_BASETEMP_PATH))
163
+ all_staged_paths = {
164
+ _temporary_path_preserving_directory_signal(
165
+ staging_root,
166
+ str(each_root / PYTEST_SCRATCH_DIRECTORY_NAME / "legacy_module.py"),
167
+ )
168
+ for each_root in all_basetemp_roots
169
+ }
170
+ assert all_staged_paths == {staging_root / "legacy_module.py"}
165
171
 
166
172
 
167
173
  def test_substring_patterns_from_path_patterns_skips_directory_tokens() -> None:
@@ -45,7 +45,7 @@ def test_run_fast_save_validators_covers_every_non_mypy_non_ruff_check(
45
45
 
46
46
  all_outcomes = run_fast_save_validators([target_file])
47
47
 
48
- assert {each_outcome.display_name for each_outcome in all_outcomes} == (
48
+ assert {each_outcome.name for each_outcome in all_outcomes} == (
49
49
  _ALL_EXPECTED_FAST_SAVE_VALIDATOR_NAMES
50
50
  )
51
51
 
@@ -56,8 +56,8 @@ def test_run_fast_save_validators_excludes_mypy_and_ruff(tmp_path: Path) -> None
56
56
 
57
57
  all_outcomes = run_fast_save_validators([target_file])
58
58
 
59
- assert all(each_outcome.display_name != "Mypy" for each_outcome in all_outcomes)
60
- assert all(each_outcome.display_name != "Ruff" for each_outcome in all_outcomes)
59
+ assert all(each_outcome.name != "Mypy" for each_outcome in all_outcomes)
60
+ assert all(each_outcome.name != "Ruff" for each_outcome in all_outcomes)
61
61
 
62
62
 
63
63
  def test_run_fast_save_validators_spawns_no_subprocess(
@@ -98,9 +98,9 @@ def test_magic_values_outcome_matches_direct_validate_file_call(
98
98
  all_outcomes = run_fast_save_validators([target_file])
99
99
 
100
100
  magic_values_outcome = next(
101
- each for each in all_outcomes if each.display_name == "Magic Values"
101
+ each for each in all_outcomes if each.name == "Magic Values"
102
102
  )
103
- assert magic_values_outcome.violation_report == "\n".join(expected_lines)
103
+ assert magic_values_outcome.output == "\n".join(expected_lines)
104
104
 
105
105
 
106
106
  def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
@@ -111,8 +111,8 @@ def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
111
111
 
112
112
  all_outcomes = run_fast_save_validators([target_file])
113
113
 
114
- todo_outcome = next(each for each in all_outcomes if each.display_name == "TODO Tracking")
115
- assert todo_outcome.violation_report == "\n".join(expected_lines)
114
+ todo_outcome = next(each for each in all_outcomes if each.name == "TODO Tracking")
115
+ assert todo_outcome.output == "\n".join(expected_lines)
116
116
 
117
117
 
118
118
  def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) -> None:
@@ -125,9 +125,9 @@ def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) ->
125
125
  all_outcomes = run_fast_save_validators([target_file])
126
126
 
127
127
  abbreviations_outcome = next(
128
- each for each in all_outcomes if each.display_name == "Abbreviations"
128
+ each for each in all_outcomes if each.name == "Abbreviations"
129
129
  )
130
- assert abbreviations_outcome.is_clean is True
130
+ assert abbreviations_outcome.passed is True
131
131
 
132
132
 
133
133
  def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> None:
@@ -139,8 +139,8 @@ def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> Non
139
139
 
140
140
  all_outcomes = run_fast_save_validators([target_file])
141
141
 
142
- react_outcome = next(each for each in all_outcomes if each.display_name == "React")
143
- assert react_outcome.violation_report == "\n".join(expected_lines)
142
+ react_outcome = next(each for each in all_outcomes if each.name == "React")
143
+ assert react_outcome.output == "\n".join(expected_lines)
144
144
 
145
145
 
146
146
  def test_test_safety_outcome_matches_direct_check_function_calls(
@@ -155,8 +155,8 @@ def test_test_safety_outcome_matches_direct_check_function_calls(
155
155
 
156
156
  all_outcomes = run_fast_save_validators([target_file])
157
157
 
158
- test_safety_outcome = next(each for each in all_outcomes if each.display_name == "Test Safety")
159
- assert test_safety_outcome.violation_report == "\n".join(expected_lines)
158
+ test_safety_outcome = next(each for each in all_outcomes if each.name == "Test Safety")
159
+ assert test_safety_outcome.output == "\n".join(expected_lines)
160
160
 
161
161
 
162
162
  def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
@@ -166,7 +166,7 @@ def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
166
166
  all_outcomes = run_fast_save_validators([target_file])
167
167
 
168
168
  abbreviations_outcome = next(
169
- each for each in all_outcomes if each.display_name == "Abbreviations"
169
+ each for each in all_outcomes if each.name == "Abbreviations"
170
170
  )
171
- assert abbreviations_outcome.is_clean is True
172
- assert abbreviations_outcome.violation_report == "No Python files to check"
171
+ assert abbreviations_outcome.passed is True
172
+ assert abbreviations_outcome.output == "No Python files to check"
@@ -106,8 +106,16 @@ def test_ensure_real_repository_target_accepts_a_repo_file_under_the_os_temp_roo
106
106
 
107
107
 
108
108
  def test_ensure_real_repository_target_refuses_a_scratch_path_outside_the_repository_root(
109
- tmp_path: Path,
109
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
110
110
  ) -> None:
111
+ """The refusal is under test, so this pins the temp root it is judged against.
112
+
113
+ ``--basetemp`` places ``tmp_path`` wherever the caller asks, so a run that
114
+ points it outside the OS temp root leaves the scratch target real and this
115
+ assertion measures the caller's flags. Naming the root here keeps the
116
+ subject of the test inside the test.
117
+ """
118
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
111
119
  repository_root = tmp_path / "repo"
112
120
  repository_root.mkdir()
113
121
  scratch_target = tmp_path / "scratch.py"
@@ -194,6 +202,7 @@ def test_measure_hosted_command_wall_times_honors_a_repository_root_override(
194
202
  gap the wrapper-plumb-through check caught.
195
203
  """
196
204
  monkeypatch.setattr(hook_timing_harness, "write_edit_hook_commands", lambda path: [])
205
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
197
206
  repository_root = tmp_path / "repo"
198
207
  real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
199
208
  real_file.parent.mkdir(parents=True)
@@ -398,3 +398,25 @@ class TestHooksSubprocessWorkingDirectory:
398
398
  )
399
399
  assert working_directory_string == tempfile.gettempdir()
400
400
  assert not working_directory_string.startswith("\\\\")
401
+
402
+
403
+ class TestDocstringExamplePaths:
404
+ def test_module_source_carries_no_home_directory_example_paths(self) -> None:
405
+ """Example paths avoid /home so the privacy gate reads no personal data.
406
+
407
+ The gate allowlists a fixed set of placeholder home usernames, so an
408
+ invented name outside that set reads as somebody's real home directory
409
+ and denies the commit. Rooting an example somewhere other than /home
410
+ keeps the illustration and leaves nothing for the gate to flag.
411
+ """
412
+ module_source = (Path(__file__).parent / "run_all_validators.py").read_text(
413
+ encoding="utf-8"
414
+ )
415
+
416
+ all_home_example_lines = [
417
+ each_line
418
+ for each_line in module_source.splitlines()
419
+ if '"/home/' in each_line
420
+ ]
421
+
422
+ assert all_home_example_lines == []
@@ -0,0 +1,249 @@
1
+ """Behavioral tests for validator coverage across every mutation tool.
2
+
3
+ The PreToolUse gate has to reach a MultiEdit and a Codex apply_patch the same
4
+ way it reaches a Write and an Edit. Two things carry that: the hooks.json
5
+ matcher the harness dispatches on, and the payload shape the gate reconstructs
6
+ post-edit content from.
7
+ """
8
+
9
+ import json
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
16
+ ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
17
+ )
18
+
19
+ from .run_all_validators import run_validators_entrypoint_subprocess
20
+
21
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
22
+
23
+ HOOKS_JSON_PATH = Path(__file__).resolve().parents[1] / "hooks.json"
24
+ VALIDATORS_RUNNER_COMMAND_MARKER = "validators.run_all_validators"
25
+ MATCHER_TOOL_NAME_SEPARATOR = "|"
26
+ DENY_DECISION_FRAGMENT = '"permissionDecision": "deny"'
27
+ MAGIC_VALUE_VALIDATOR_NAME = "Magic Values"
28
+
29
+ CLEAN_MODULE_SOURCE = (
30
+ "def clean_marker() -> int:\n computed_value = 1\n return computed_value\n"
31
+ )
32
+ CLEAN_SECOND_MODULE_SOURCE = (
33
+ "def second_marker() -> int:\n counted_value = 1\n return counted_value\n"
34
+ )
35
+ CLEAN_RETURN_LINE = " return computed_value\n"
36
+ MAGIC_VALUE_RETURN_LINE = " return computed_value * 199\n"
37
+ CLEAN_REWRITTEN_RETURN_LINE = " return computed_value + 1\n"
38
+
39
+ UPDATE_PATCH_TEMPLATE = (
40
+ "*** Begin Patch\n"
41
+ "*** Update File: {relative_path}\n"
42
+ "@@\n"
43
+ "-{removed_line}"
44
+ "+{added_line}"
45
+ "*** End Patch\n"
46
+ )
47
+ ADD_VIOLATING_FILE_PATCH_COMMAND = (
48
+ "*** Begin Patch\n"
49
+ "*** Add File: added_module.py\n"
50
+ "+def added_marker() -> int:\n"
51
+ "+ return 199\n"
52
+ "*** End Patch\n"
53
+ )
54
+ DELETE_FILE_PATCH_COMMAND = (
55
+ "*** Begin Patch\n*** Delete File: legacy_module.py\n*** End of File\n*** End Patch\n"
56
+ )
57
+ UNPARSEABLE_PATCH_COMMAND = "not a codex patch at all\n"
58
+ TWO_FILE_PATCH_COMMAND = (
59
+ "*** Begin Patch\n"
60
+ "*** Update File: first_module.py\n"
61
+ "@@\n"
62
+ "- return computed_value\n"
63
+ "+ return computed_value + 1\n"
64
+ "*** Update File: second_module.py\n"
65
+ "@@\n"
66
+ "- return counted_value\n"
67
+ "+ return counted_value * 199\n"
68
+ "*** End Patch\n"
69
+ )
70
+
71
+
72
+ def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
73
+ """Drive the real --pre-tool-use entry point with one payload."""
74
+ return run_validators_entrypoint_subprocess(["--pre-tool-use"], stdin_text=json.dumps(payload))
75
+
76
+
77
+ def update_patch_command(relative_path: str, added_line: str) -> str:
78
+ """Build an Update File patch rewriting the module's return line."""
79
+ return UPDATE_PATCH_TEMPLATE.format(
80
+ relative_path=relative_path,
81
+ removed_line=CLEAN_RETURN_LINE,
82
+ added_line=added_line,
83
+ )
84
+
85
+
86
+ def write_clean_module(target_directory: Path, module_name: str) -> Path:
87
+ """Write the clean Python module the payloads target."""
88
+ target_file = target_directory / module_name
89
+ target_file.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
90
+ return target_file
91
+
92
+
93
+ class TestMutationToolRegistration:
94
+ def test_validators_gate_is_registered_on_every_mutation_tool(self) -> None:
95
+ hooks_document = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
96
+ all_matchers = [
97
+ each_group["matcher"]
98
+ for each_group in hooks_document["hooks"]["PreToolUse"]
99
+ for each_hook in each_group.get("hooks", [])
100
+ if VALIDATORS_RUNNER_COMMAND_MARKER in each_hook.get("command", "")
101
+ ]
102
+ assert len(all_matchers) == 1, all_matchers
103
+ assert (
104
+ set(all_matchers[0].split(MATCHER_TOOL_NAME_SEPARATOR))
105
+ == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
106
+ )
107
+
108
+
109
+ class TestMultiEditPayloadLane:
110
+ def test_multi_edit_introducing_a_violation_denies(self, tmp_path: Path) -> None:
111
+ target_file = write_clean_module(tmp_path, "legacy_module.py")
112
+ completed = run_gate(
113
+ {
114
+ "tool_name": "MultiEdit",
115
+ "tool_input": {
116
+ "file_path": str(target_file),
117
+ "edits": [
118
+ {
119
+ "old_string": CLEAN_RETURN_LINE,
120
+ "new_string": MAGIC_VALUE_RETURN_LINE,
121
+ }
122
+ ],
123
+ },
124
+ }
125
+ )
126
+ assert completed.returncode == 0, completed.stderr
127
+ assert DENY_DECISION_FRAGMENT in completed.stdout
128
+ assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
129
+
130
+ def test_multi_edit_leaving_clean_content_allows(self, tmp_path: Path) -> None:
131
+ target_file = write_clean_module(tmp_path, "clean_module.py")
132
+ completed = run_gate(
133
+ {
134
+ "tool_name": "MultiEdit",
135
+ "tool_input": {
136
+ "file_path": str(target_file),
137
+ "edits": [
138
+ {
139
+ "old_string": CLEAN_RETURN_LINE,
140
+ "new_string": CLEAN_REWRITTEN_RETURN_LINE,
141
+ }
142
+ ],
143
+ },
144
+ }
145
+ )
146
+ assert completed.returncode == 0, completed.stderr
147
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
148
+
149
+
150
+ class TestApplyPatchPayloadLane:
151
+ def test_apply_patch_introducing_a_violation_denies(self, tmp_path: Path) -> None:
152
+ write_clean_module(tmp_path, "legacy_module.py")
153
+ completed = run_gate(
154
+ {
155
+ "tool_name": "apply_patch",
156
+ "tool_input": {
157
+ "command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
158
+ },
159
+ "cwd": str(tmp_path),
160
+ }
161
+ )
162
+ assert completed.returncode == 0, completed.stderr
163
+ assert DENY_DECISION_FRAGMENT in completed.stdout
164
+ assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
165
+
166
+ def test_apply_patch_deny_reason_names_the_patched_file(self, tmp_path: Path) -> None:
167
+ target_file = write_clean_module(tmp_path, "legacy_module.py")
168
+ completed = run_gate(
169
+ {
170
+ "tool_name": "apply_patch",
171
+ "tool_input": {
172
+ "command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
173
+ },
174
+ "cwd": str(tmp_path),
175
+ }
176
+ )
177
+ assert completed.returncode == 0, completed.stderr
178
+ assert target_file.name in completed.stdout
179
+
180
+ def test_apply_patch_leaving_clean_content_allows(self, tmp_path: Path) -> None:
181
+ write_clean_module(tmp_path, "clean_module.py")
182
+ completed = run_gate(
183
+ {
184
+ "tool_name": "apply_patch",
185
+ "tool_input": {
186
+ "command": update_patch_command("clean_module.py", CLEAN_REWRITTEN_RETURN_LINE)
187
+ },
188
+ "cwd": str(tmp_path),
189
+ }
190
+ )
191
+ assert completed.returncode == 0, completed.stderr
192
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
193
+
194
+ def test_apply_patch_adding_a_violating_file_denies(self, tmp_path: Path) -> None:
195
+ completed = run_gate(
196
+ {
197
+ "tool_name": "apply_patch",
198
+ "tool_input": {"command": ADD_VIOLATING_FILE_PATCH_COMMAND},
199
+ "cwd": str(tmp_path),
200
+ }
201
+ )
202
+ assert completed.returncode == 0, completed.stderr
203
+ assert DENY_DECISION_FRAGMENT in completed.stdout
204
+ assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
205
+
206
+ def test_apply_patch_deleting_a_file_allows(self, tmp_path: Path) -> None:
207
+ write_clean_module(tmp_path, "legacy_module.py")
208
+ completed = run_gate(
209
+ {
210
+ "tool_name": "apply_patch",
211
+ "tool_input": {"command": DELETE_FILE_PATCH_COMMAND},
212
+ "cwd": str(tmp_path),
213
+ }
214
+ )
215
+ assert completed.returncode == 0, completed.stderr
216
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
217
+
218
+ def test_unparseable_apply_patch_command_allows(self, tmp_path: Path) -> None:
219
+ """A patch this lane cannot read is the enforcer's to deny, not this gate's.
220
+
221
+ ``code_rules_enforcer`` denies a payload whose patch markers it cannot
222
+ parse. Both hooks run on the same call and any denial wins, so this lane
223
+ allows rather than raising a second denial for one cause. Moving that
224
+ denial out of the enforcer leaves a malformed patch with no reader.
225
+ """
226
+ completed = run_gate(
227
+ {
228
+ "tool_name": "apply_patch",
229
+ "tool_input": {"command": UNPARSEABLE_PATCH_COMMAND},
230
+ "cwd": str(tmp_path),
231
+ }
232
+ )
233
+ assert completed.returncode == 0, completed.stderr
234
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
235
+
236
+ def test_apply_patch_denies_a_violation_in_its_second_file(self, tmp_path: Path) -> None:
237
+ write_clean_module(tmp_path, "first_module.py")
238
+ second_file = tmp_path / "second_module.py"
239
+ second_file.write_text(CLEAN_SECOND_MODULE_SOURCE, encoding="utf-8")
240
+ completed = run_gate(
241
+ {
242
+ "tool_name": "apply_patch",
243
+ "tool_input": {"command": TWO_FILE_PATCH_COMMAND},
244
+ "cwd": str(tmp_path),
245
+ }
246
+ )
247
+ assert completed.returncode == 0, completed.stderr
248
+ assert DENY_DECISION_FRAGMENT in completed.stdout
249
+ assert second_file.name in completed.stdout
@@ -19,6 +19,7 @@ from .run_all_validators import (
19
19
  ValidatorResult,
20
20
  _escapes_temporary_root,
21
21
  _hooks_subprocess_working_directory_and_environment,
22
+ _scope_agnostic_message,
22
23
  _scope_new_and_preexisting,
23
24
  _temporary_path_preserving_directory_signal,
24
25
  _violation_line_number,
@@ -33,6 +34,9 @@ CONFIG_DIR_TARGET_PATH = (
33
34
  )
34
35
  PARENT_TRAVERSAL_TARGET_PATH = "../../escape_target.py"
35
36
  RELATIVE_CONFIG_TARGET_PATH = "config/x.py"
37
+ PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH = (
38
+ "/srv/projects/demo/test_edit_introducing_new_viol0/legacy_module.py"
39
+ )
36
40
 
37
41
  CLEAN_PYTHON_SOURCE = (
38
42
  "def add_two_numbers(first_number: int, second_number: int) -> int:\n"
@@ -228,6 +232,38 @@ class TestTemporaryPathPreservingDirectorySignal:
228
232
  )
229
233
  assert staged_path == staging_root / "legacy_module.py"
230
234
 
235
+ def test_pytest_scratch_segment_stages_flat_outside_every_temporary_root(
236
+ self, tmp_path: Path
237
+ ) -> None:
238
+ staging_root = tmp_path / "staging_root"
239
+ staging_root.mkdir()
240
+ staged_path = _temporary_path_preserving_directory_signal(
241
+ staging_root, PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH
242
+ )
243
+ assert staged_path == staging_root / "legacy_module.py"
244
+
245
+ def test_test_helpers_directory_keeps_its_exemption_under_a_temporary_root(
246
+ self, tmp_path: Path
247
+ ) -> None:
248
+ helpers_target = tmp_path / "pkg" / "test_helpers" / "worker.py"
249
+ staging_root = tmp_path / "staging_root"
250
+ staging_root.mkdir()
251
+ staged_path = _temporary_path_preserving_directory_signal(
252
+ staging_root, str(helpers_target)
253
+ )
254
+ assert staged_path == staging_root / "test_helpers" / "worker.py"
255
+
256
+ def test_scripts_directory_keeps_its_exemption_under_a_temporary_root(
257
+ self, tmp_path: Path
258
+ ) -> None:
259
+ scripts_target = tmp_path / "pkg" / "scripts" / "run_job.py"
260
+ staging_root = tmp_path / "staging_root"
261
+ staging_root.mkdir()
262
+ staged_path = _temporary_path_preserving_directory_signal(
263
+ staging_root, str(scripts_target)
264
+ )
265
+ assert staged_path == staging_root / "scripts" / "run_job.py"
266
+
231
267
  def test_relative_config_path_preserves_config_under_system_temp_cwd(
232
268
  self, monkeypatch: pytest.MonkeyPatch
233
269
  ) -> None:
@@ -430,6 +466,100 @@ class TestBaselineScopedGate:
430
466
  assert '"permissionDecision": "deny"' in completed.stdout
431
467
  assert "Magic Values" in completed.stdout
432
468
 
469
+ def test_renaming_a_function_keeps_its_violation_grandfathered(
470
+ self, tmp_path: Path
471
+ ) -> None:
472
+ target_file = tmp_path / "legacy_module.py"
473
+ target_file.write_text(
474
+ _over_long_function_source("over_long_function"), encoding="utf-8"
475
+ )
476
+ completed = run_gate(
477
+ {
478
+ "tool_name": "Edit",
479
+ "tool_input": {
480
+ "file_path": str(target_file),
481
+ "old_string": "def over_long_function(",
482
+ "new_string": "def accumulate_running_total(",
483
+ },
484
+ }
485
+ )
486
+ assert completed.returncode == 0, completed.stderr
487
+ assert '"permissionDecision": "deny"' not in completed.stdout
488
+ assert "Code Quality" in completed.stderr
489
+
490
+ def test_renaming_a_function_denies_a_second_violation_of_the_same_shape(
491
+ self, tmp_path: Path
492
+ ) -> None:
493
+ target_file = tmp_path / "legacy_module.py"
494
+ target_file.write_text(
495
+ _over_long_function_source("over_long_function"), encoding="utf-8"
496
+ )
497
+ completed = run_gate(
498
+ {
499
+ "tool_name": "Edit",
500
+ "tool_input": {
501
+ "file_path": str(target_file),
502
+ "old_string": "def over_long_function(",
503
+ "new_string": (
504
+ _over_long_function_source("second_over_long_function")
505
+ + "\n\n"
506
+ + "def accumulate_running_total("
507
+ ),
508
+ },
509
+ }
510
+ )
511
+ assert completed.returncode == 0, completed.stderr
512
+ assert '"permissionDecision": "deny"' in completed.stdout
513
+ assert "Code Quality" in completed.stdout
514
+
515
+ def test_renaming_a_function_denies_a_new_violation_of_another_validator(
516
+ self, tmp_path: Path
517
+ ) -> None:
518
+ target_file = tmp_path / "legacy_module.py"
519
+ target_file.write_text(
520
+ _over_long_function_source("over_long_function"), encoding="utf-8"
521
+ )
522
+ completed = run_gate(
523
+ {
524
+ "tool_name": "Edit",
525
+ "tool_input": {
526
+ "file_path": str(target_file),
527
+ "old_string": "def over_long_function(",
528
+ "new_string": "import os\n\n\ndef accumulate_running_total(",
529
+ },
530
+ }
531
+ )
532
+ assert completed.returncode == 0, completed.stderr
533
+ assert '"permissionDecision": "deny"' in completed.stdout
534
+ assert "F401" in completed.stdout
535
+
536
+ def test_moving_a_violation_to_another_function_keeps_it_grandfathered(
537
+ self, tmp_path: Path
538
+ ) -> None:
539
+ target_file = tmp_path / "legacy_module.py"
540
+ target_file.write_text(RUFF_DIRTY_FUNCTION_SOURCE, encoding="utf-8")
541
+ completed = run_gate(
542
+ {
543
+ "tool_name": "Edit",
544
+ "tool_input": {
545
+ "file_path": str(target_file),
546
+ "old_string": RUFF_DIRTY_FUNCTION_SOURCE,
547
+ "new_string": (
548
+ "def compute_total() -> int:\n"
549
+ " return 1\n"
550
+ "\n"
551
+ "\n"
552
+ "def compute_subtotal() -> int:\n"
553
+ " unused_intermediate = 1\n"
554
+ " return 1\n"
555
+ ),
556
+ },
557
+ }
558
+ )
559
+ assert completed.returncode == 0, completed.stderr
560
+ assert '"permissionDecision": "deny"' not in completed.stdout
561
+ assert "F841" in completed.stderr
562
+
433
563
  def test_new_module_scope_ruff_violation_with_dirty_function_denies(
434
564
  self, tmp_path: Path
435
565
  ) -> None:
@@ -451,6 +581,37 @@ class TestBaselineScopedGate:
451
581
  assert "F841" not in completed.stdout
452
582
 
453
583
 
584
+ class TestScopeAgnosticMessage:
585
+ def test_masks_the_enclosing_function_name_and_its_metrics(self) -> None:
586
+ masked_message = _scope_agnostic_message(
587
+ "over_long_function", "Function 'over_long_function' is 37 lines (max 30)"
588
+ )
589
+
590
+ assert masked_message == "Function '<name>' is <number> lines (max <number>)"
591
+
592
+ def test_keeps_the_numbers_of_a_message_that_never_names_its_function(self) -> None:
593
+ magic_value_message = "Magic number 199 - use named constant"
594
+
595
+ masked_message = _scope_agnostic_message("compute_total", magic_value_message)
596
+
597
+ assert masked_message == magic_value_message
598
+
599
+ def test_keeps_a_module_scope_message_unchanged(self) -> None:
600
+ ruff_message = "F401 `os` imported but unused"
601
+
602
+ masked_message = _scope_agnostic_message("", ruff_message)
603
+
604
+ assert masked_message == ruff_message
605
+
606
+ def test_masks_only_the_innermost_name_of_a_qualified_scope(self) -> None:
607
+ masked_message = _scope_agnostic_message(
608
+ "Ledger.record_entry",
609
+ "Parameter 'entry' in 'record_entry' missing type annotation",
610
+ )
611
+
612
+ assert masked_message == "Parameter 'entry' in '<name>' missing type annotation"
613
+
614
+
454
615
  class TestScopeNewAndPreexisting:
455
616
  def test_failed_result_without_located_lines_is_treated_as_new(self) -> None:
456
617
  summary_only_result = ValidatorResult(