claude-dev-env 2.30.1 → 2.31.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 (118) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +11 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_enforcer.py +57 -31
  13. package/hooks/blocking/codex_apply_patch.py +136 -21
  14. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  15. package/hooks/blocking/conftest.py +70 -0
  16. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  17. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  18. package/hooks/blocking/pii_payload_scan.py +61 -0
  19. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  20. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  22. package/hooks/blocking/sensitive_file_protector.py +18 -3
  23. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  24. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  25. package/hooks/blocking/state_description_blocker.py +39 -26
  26. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  27. package/hooks/blocking/tdd_enforcer.py +57 -14
  28. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  29. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  30. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  31. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  32. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  33. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  36. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  38. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  40. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  41. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  42. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  45. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  46. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  48. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  49. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  50. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  51. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  52. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  53. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  54. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  55. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  56. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  57. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  58. package/hooks/blocking/test_state_description_blocker.py +68 -0
  59. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  60. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  61. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  62. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  63. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  64. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  65. package/hooks/conftest.py +47 -0
  66. package/hooks/hooks.json +7 -12
  67. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  68. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  70. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  71. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  73. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  74. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  75. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  76. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  77. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  78. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  81. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  83. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  84. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  85. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  86. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  87. package/hooks/observability/session_file_edit_tracker.py +25 -4
  88. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  89. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  90. package/hooks/validation/hook_format_validator.py +23 -9
  91. package/hooks/validation/test_hook_format_validator.py +58 -0
  92. package/hooks/validators/README.md +1 -1
  93. package/hooks/validators/abbreviation_checks.py +3 -5
  94. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  95. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  96. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  97. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  98. package/hooks/validators/fast_save_validators.py +332 -0
  99. package/hooks/validators/hook_timing_harness.py +8 -4
  100. package/hooks/validators/python_style_checks.py +34 -60
  101. package/hooks/validators/python_style_helpers.py +16 -2
  102. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  103. package/hooks/validators/run_all_validators.py +42 -41
  104. package/hooks/validators/test_abbreviation_checks.py +20 -0
  105. package/hooks/validators/test_fast_save_validators.py +172 -0
  106. package/hooks/validators/test_python_style_checks.py +9 -0
  107. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  108. package/hooks/validators/test_python_style_helpers.py +35 -0
  109. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  110. package/hooks/validators/test_run_all_validators.py +46 -0
  111. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  112. package/package.json +1 -1
  113. package/rules/docstring-prose-matches-implementation.md +1 -1
  114. package/rules/git-workflow.md +2 -0
  115. package/rules/testing.md +4 -0
  116. package/scripts/codex_compat_materializer.py +5 -0
  117. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  118. package/hooks/blocking/tests/conftest.py +0 -10
@@ -0,0 +1,143 @@
1
+ """Behavioral tests for the content_hash_store parts module."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from tdd_enforcer_parts import content_hash_store
9
+
10
+
11
+ def _write_fresh_test_file(directory: Path, content: str = "def test_fulfill(): pass\n") -> Path:
12
+ candidate = directory / "test_orders.py"
13
+ candidate.write_text(content)
14
+ return candidate
15
+
16
+
17
+ def test_state_file_path_differs_by_repository_root(tmp_path: Path) -> None:
18
+ first_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "one"))
19
+ second_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "two"))
20
+ assert first_root_path != second_root_path
21
+
22
+
23
+ def test_state_file_path_is_stable_for_the_same_inputs(tmp_path: Path) -> None:
24
+ first_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
25
+ second_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
26
+ assert first_lookup == second_lookup
27
+
28
+
29
+ def test_first_sight_allows_and_stores_a_fresh_dirty_test(tmp_path: Path) -> None:
30
+ candidate = _write_fresh_test_file(tmp_path)
31
+
32
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
33
+ [candidate], "session-1", str(tmp_path), 600
34
+ )
35
+
36
+ assert is_allowed is True
37
+ state_file = content_hash_store._state_file_path("session-1", str(tmp_path))
38
+ state = json.loads(state_file.read_text())
39
+ assert content_hash_store._state_key_for(candidate) in state
40
+
41
+
42
+ def test_first_sight_denies_when_test_evidence_is_missing(tmp_path: Path) -> None:
43
+ candidate = _write_fresh_test_file(tmp_path, content="orders = 'not a test'\n")
44
+
45
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
46
+ [candidate], "session-2", str(tmp_path), 600
47
+ )
48
+
49
+ assert is_allowed is False
50
+
51
+
52
+ def test_first_sight_denies_when_test_file_is_stale(tmp_path: Path) -> None:
53
+ candidate = _write_fresh_test_file(tmp_path)
54
+ stale_timestamp = time.time() - 700
55
+ os.utime(candidate, (stale_timestamp, stale_timestamp))
56
+
57
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
58
+ [candidate], "session-3", str(tmp_path), 600
59
+ )
60
+
61
+ assert is_allowed is False
62
+
63
+
64
+ def test_second_sight_denies_a_touch_that_leaves_content_unchanged(tmp_path: Path) -> None:
65
+ candidate = _write_fresh_test_file(tmp_path)
66
+ session_id, repository_root = "session-4", str(tmp_path)
67
+ assert content_hash_store.has_recorded_or_fresh_test(
68
+ [candidate], session_id, repository_root, 600
69
+ )
70
+ state_file = content_hash_store._state_file_path(session_id, repository_root)
71
+ state = json.loads(state_file.read_text())
72
+ candidate_key = content_hash_store._state_key_for(candidate)
73
+ state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
74
+ state_file.write_text(json.dumps(state))
75
+ candidate.touch()
76
+
77
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
78
+ [candidate], session_id, repository_root, 600
79
+ )
80
+
81
+ assert is_allowed is False
82
+
83
+
84
+ def test_second_sight_allows_when_content_actually_changed_after_the_window(
85
+ tmp_path: Path,
86
+ ) -> None:
87
+ candidate = _write_fresh_test_file(tmp_path)
88
+ session_id, repository_root = "session-5", str(tmp_path)
89
+ assert content_hash_store.has_recorded_or_fresh_test(
90
+ [candidate], session_id, repository_root, 600
91
+ )
92
+ state_file = content_hash_store._state_file_path(session_id, repository_root)
93
+ state = json.loads(state_file.read_text())
94
+ candidate_key = content_hash_store._state_key_for(candidate)
95
+ state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
96
+ state_file.write_text(json.dumps(state))
97
+ candidate.write_text("def test_fulfill(): assert True\n")
98
+
99
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
100
+ [candidate], session_id, repository_root, 600
101
+ )
102
+
103
+ assert is_allowed is True
104
+
105
+
106
+ def test_second_sight_allows_a_repeat_write_within_the_window_untouched(
107
+ tmp_path: Path,
108
+ ) -> None:
109
+ candidate = _write_fresh_test_file(tmp_path)
110
+ session_id, repository_root = "session-6", str(tmp_path)
111
+ assert content_hash_store.has_recorded_or_fresh_test(
112
+ [candidate], session_id, repository_root, 600
113
+ )
114
+
115
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
116
+ [candidate], session_id, repository_root, 600
117
+ )
118
+
119
+ assert is_allowed is True
120
+
121
+
122
+ def test_any_candidate_satisfying_the_gate_allows_the_write(tmp_path: Path) -> None:
123
+ stale_candidate = _write_fresh_test_file(tmp_path)
124
+ stale_timestamp = time.time() - 700
125
+ os.utime(stale_candidate, (stale_timestamp, stale_timestamp))
126
+ fresh_candidate = tmp_path / "orders_test.py"
127
+ fresh_candidate.write_text("def test_fulfill(): pass\n")
128
+
129
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
130
+ [stale_candidate, fresh_candidate], "session-7", str(tmp_path), 600
131
+ )
132
+
133
+ assert is_allowed is True
134
+
135
+
136
+ def test_denies_when_no_candidate_file_exists(tmp_path: Path) -> None:
137
+ missing_candidate = tmp_path / "test_missing.py"
138
+
139
+ is_allowed = content_hash_store.has_recorded_or_fresh_test(
140
+ [missing_candidate], "session-8", str(tmp_path), 600
141
+ )
142
+
143
+ assert is_allowed is False
@@ -20,6 +20,18 @@ def test_build_deny_reason_points_exemption_guidance_at_entry_hook() -> None:
20
20
  assert "decisions.py" not in reason
21
21
 
22
22
 
23
+ def test_build_deny_reason_names_content_not_only_modification_time() -> None:
24
+ reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")])
25
+ assert "content" in reason.lower()
26
+
27
+
28
+ def test_build_deny_reason_covers_first_sight_and_stale_hash_and_names_the_fix() -> None:
29
+ reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")]).lower()
30
+ assert "fresh" in reason
31
+ assert "change" in reason
32
+ assert "touch" in reason
33
+
34
+
23
35
  def test_emit_allow_writes_allow_decision(capsys: pytest.CaptureFixture[str]) -> None:
24
36
  decisions.emit_allow()
25
37
  parsed = json.loads(capsys.readouterr().out)
@@ -46,3 +46,66 @@ def test_absent_but_tracked_false_when_file_present(tmp_path: Path) -> None:
46
46
  _git(tmp_path, "add", "-A")
47
47
  _git(tmp_path, "commit", "-q", "-m", "init")
48
48
  assert git_tracking.is_absent_but_tracked(present) is False
49
+
50
+
51
+ def test_has_uncommitted_change_from_head_true_for_untracked_file(tmp_path: Path) -> None:
52
+ _init_repository(tmp_path)
53
+ untracked = tmp_path / "test_orders.py"
54
+ untracked.write_text("def test_fulfill(): pass\n")
55
+ assert git_tracking.has_uncommitted_change_from_head(untracked) is True
56
+
57
+
58
+ def test_has_uncommitted_change_from_head_true_for_tracked_modified_file(tmp_path: Path) -> None:
59
+ _init_repository(tmp_path)
60
+ tracked = tmp_path / "test_orders.py"
61
+ tracked.write_text("def test_fulfill(): pass\n")
62
+ _git(tmp_path, "add", "-A")
63
+ _git(tmp_path, "commit", "-q", "-m", "init")
64
+ tracked.write_text("def test_fulfill(): assert True\n")
65
+ assert git_tracking.has_uncommitted_change_from_head(tracked) is True
66
+
67
+
68
+ def test_has_uncommitted_change_from_head_false_for_tracked_clean_file(tmp_path: Path) -> None:
69
+ _init_repository(tmp_path)
70
+ tracked = tmp_path / "test_orders.py"
71
+ tracked.write_text("def test_fulfill(): pass\n")
72
+ _git(tmp_path, "add", "-A")
73
+ _git(tmp_path, "commit", "-q", "-m", "init")
74
+ tracked.touch()
75
+ assert git_tracking.has_uncommitted_change_from_head(tracked) is False
76
+
77
+
78
+ def test_has_uncommitted_change_from_head_true_outside_a_git_repository(tmp_path: Path) -> None:
79
+ stray = tmp_path / "test_orders.py"
80
+ stray.write_text("def test_fulfill(): pass\n")
81
+ assert git_tracking.has_uncommitted_change_from_head(stray) is True
82
+
83
+
84
+ def test_has_uncommitted_change_from_head_true_for_staged_new_file_never_committed(
85
+ tmp_path: Path,
86
+ ) -> None:
87
+ """A brand-new file only ``git add``-ed carries content HEAD never had.
88
+
89
+ Regression guard: comparing the working tree to the index (rather than to
90
+ HEAD) reads a freshly staged file as unchanged, since staging makes the
91
+ index match the working tree exactly. That misses every staged-but-never-
92
+ committed file, which is exactly how a RED-step test file is staged.
93
+ """
94
+ _init_repository(tmp_path)
95
+ _git(tmp_path, "commit", "--allow-empty", "-q", "-m", "baseline")
96
+ staged_new_test = tmp_path / "test_orders.py"
97
+ staged_new_test.write_text("def test_fulfill(): pass\n")
98
+ _git(tmp_path, "add", "-A")
99
+ assert git_tracking.has_uncommitted_change_from_head(staged_new_test) is True
100
+
101
+
102
+ def test_has_uncommitted_change_from_head_true_for_staged_modification(tmp_path: Path) -> None:
103
+ """A tracked test's staged edit still differs from HEAD, not only the index."""
104
+ _init_repository(tmp_path)
105
+ tracked = tmp_path / "test_orders.py"
106
+ tracked.write_text("def test_fulfill(): pass\n")
107
+ _git(tmp_path, "add", "-A")
108
+ _git(tmp_path, "commit", "-q", "-m", "init")
109
+ tracked.write_text("def test_fulfill(): assert True\n")
110
+ _git(tmp_path, "add", "-A")
111
+ assert git_tracking.has_uncommitted_change_from_head(tracked) is True
@@ -222,6 +222,7 @@ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
222
222
  "blocking/cursor_cli_python_misfire_blocker.py",
223
223
  "blocking/unscoped_search_blocker.py",
224
224
  "blocking/pii_prevention_blocker.py",
225
+ "blocking/pr_description_writer_gate.py",
225
226
  ]
226
227
 
227
228
 
@@ -24,6 +24,8 @@ consideration will trip this test.
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import ast
28
+ import functools
27
29
  import importlib.util
28
30
  import inspect
29
31
  import pathlib
@@ -31,12 +33,13 @@ import sys
31
33
 
32
34
 
33
35
  _HOOK_DIRECTORY = pathlib.Path(__file__).parent
36
+ _ENFORCER_SOURCE_PATH = _HOOK_DIRECTORY / "code_rules_enforcer.py"
34
37
  if str(_HOOK_DIRECTORY) not in sys.path:
35
38
  sys.path.insert(0, str(_HOOK_DIRECTORY))
36
39
 
37
40
  _hook_spec = importlib.util.spec_from_file_location(
38
41
  "code_rules_enforcer",
39
- _HOOK_DIRECTORY / "code_rules_enforcer.py",
42
+ _ENFORCER_SOURCE_PATH,
40
43
  )
41
44
  assert _hook_spec is not None
42
45
  assert _hook_spec.loader is not None
@@ -45,11 +48,14 @@ _hook_spec.loader.exec_module(_hook_module)
45
48
 
46
49
  VOID_ADVISORY_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
47
50
  {
51
+ "check_constants_outside_config_advisory",
48
52
  "check_duplicated_format_patterns",
49
53
  "check_flag_gated_scenario_test_naming",
50
54
  }
51
55
  )
52
56
 
57
+ PAYLOAD_COLLECTING_METHOD_NAMES: frozenset[str] = frozenset({"extend", "append"})
58
+
53
59
  DIFF_SCOPED_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
54
60
  {
55
61
  "check_banned_noun_word_boundary",
@@ -65,7 +71,6 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
65
71
  "check_comment_changes",
66
72
  "check_constant_equality_tests",
67
73
  "check_constants_outside_config",
68
- "check_constants_outside_config_advisory",
69
74
  "check_existence_check_tests",
70
75
  "check_file_global_constants_use_count",
71
76
  "check_imports_at_top",
@@ -174,6 +179,80 @@ def test_void_advisory_checks_are_registered_and_disjoint() -> None:
174
179
  )
175
180
 
176
181
 
182
+ @functools.lru_cache(maxsize=1)
183
+ def _enforcer_syntax_tree() -> ast.Module:
184
+ """Parse code_rules_enforcer.py once and hand the same tree to every caller.
185
+
186
+ The source does not change while the suite runs, so re-reading and
187
+ re-parsing it for each test buys nothing.
188
+ """
189
+ return ast.parse(_ENFORCER_SOURCE_PATH.read_text(encoding="utf-8"))
190
+
191
+
192
+ def _void_advisory_names_called_under(node: ast.AST) -> set[str]:
193
+ return {
194
+ each_node.func.id
195
+ for each_node in ast.walk(node)
196
+ if isinstance(each_node, ast.Call)
197
+ and isinstance(each_node.func, ast.Name)
198
+ and each_node.func.id in VOID_ADVISORY_CHECK_FUNCTION_NAMES
199
+ }
200
+
201
+
202
+ def _payload_collecting_calls(syntax_tree: ast.Module) -> list[ast.Call]:
203
+ return [
204
+ each_node
205
+ for each_node in ast.walk(syntax_tree)
206
+ if isinstance(each_node, ast.Call)
207
+ and isinstance(each_node.func, ast.Attribute)
208
+ and each_node.func.attr in PAYLOAD_COLLECTING_METHOD_NAMES
209
+ ]
210
+
211
+
212
+ def _void_advisory_names_fed_into_the_deny_payload(syntax_tree: ast.Module) -> set[str]:
213
+ """Return void-advisory names whose result is collected into the blocking payload.
214
+
215
+ The enforcer builds its deny payload by extending a list::
216
+
217
+ ok: check_duplicated_format_patterns(content, file_path)
218
+ flag: all_issues.extend(check_duplicated_format_patterns(content, file_path))
219
+
220
+ The flagged form routes a stderr-only advisory into the payload that denies
221
+ the write, so the advisory stops being advisory.
222
+ """
223
+ fed_names: set[str] = set()
224
+ for each_call in _payload_collecting_calls(syntax_tree):
225
+ for each_argument in each_call.args:
226
+ fed_names |= _void_advisory_names_called_under(each_argument)
227
+ return fed_names
228
+
229
+
230
+ def _bare_call_names(syntax_tree: ast.Module) -> set[str]:
231
+ return {
232
+ each_node.value.func.id
233
+ for each_node in ast.walk(syntax_tree)
234
+ if isinstance(each_node, ast.Expr)
235
+ and isinstance(each_node.value, ast.Call)
236
+ and isinstance(each_node.value.func, ast.Name)
237
+ }
238
+
239
+
240
+ def test_void_advisory_checks_never_feed_the_deny_payload() -> None:
241
+ fed_names = _void_advisory_names_fed_into_the_deny_payload(_enforcer_syntax_tree())
242
+ assert fed_names == set(), (
243
+ f"Void-advisory checks must run for their stderr side effect alone, never collected "
244
+ f"into the blocking payload. Fed into extend/append: {sorted(fed_names)}"
245
+ )
246
+
247
+
248
+ def test_void_advisory_checks_are_each_called_by_the_enforcer() -> None:
249
+ uncalled_names = VOID_ADVISORY_CHECK_FUNCTION_NAMES - _bare_call_names(_enforcer_syntax_tree())
250
+ assert uncalled_names == set(), (
251
+ f"Every void-advisory check must appear as a bare call in code_rules_enforcer.py so "
252
+ f"its findings reach stderr. Never called: {sorted(uncalled_names)}"
253
+ )
254
+
255
+
177
256
  def test_diff_scoped_checks_are_registered_and_disjoint() -> None:
178
257
  all_check_names = set(_all_check_function_names())
179
258
  assert DIFF_SCOPED_CHECK_FUNCTION_NAMES <= all_check_names, (
@@ -20,6 +20,8 @@ if str(_HOOKS_PARENT) not in sys.path:
20
20
  import code_rules_enforcer
21
21
  import codex_apply_patch
22
22
 
23
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
24
+
23
25
 
24
26
  def _run_codex_payload(
25
27
  payload: dict[str, object],
@@ -146,6 +148,39 @@ def test_codex_payload_blocks_malformed_patch(
146
148
  deny_payload = json.loads(stdout)
147
149
  assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
148
150
  assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
151
+
152
+
153
+ def test_codex_patch_operation_targets_names_every_section_without_reading_content(
154
+ tmp_path: Path, three_operation_patch: str
155
+ ) -> None:
156
+ """The path-only helper resolves every section's target without any disk read.
157
+
158
+ Unlike ``parse_codex_apply_patch``, it never reads prior file content, so a
159
+ caller that only needs each target path is not tripped up by an unreadable
160
+ prior file.
161
+ """
162
+ all_targets = codex_apply_patch.codex_patch_operation_targets(
163
+ three_operation_patch, str(tmp_path)
164
+ )
165
+
166
+ all_target_names = {
167
+ (each_operation, Path(each_path).name) for each_operation, each_path in all_targets
168
+ }
169
+ assert all_target_names == {
170
+ ("update", "updated.py"),
171
+ (codex_apply_patch.CODEX_ADD_OPERATION, "added.py"),
172
+ ("delete", "deleted.py"),
173
+ }
174
+
175
+
176
+ def test_codex_patch_operation_targets_rejects_path_traversal(tmp_path: Path) -> None:
177
+ """A patch path that escapes the working directory is rejected before any read."""
178
+ escaping_patch = "*** Begin Patch\n*** Add File: ../outside.py\n+content\n*** End Patch"
179
+
180
+ with pytest.raises(codex_apply_patch.CodexPatchError):
181
+ codex_apply_patch.codex_patch_operation_targets(escaping_patch, str(tmp_path))
182
+
183
+
149
184
  def test_codex_payload_blocks_a_nul_containing_patch_path(
150
185
  tmp_path: Path,
151
186
  monkeypatch: pytest.MonkeyPatch,
@@ -4,6 +4,7 @@ Covers:
4
4
  - is_config_file: must use directory-segment matching, not filename-stem matching
5
5
  - check_constants_outside_config: advisory (not blocking) for function-body UPPER_SNAKE
6
6
  - check_constants_outside_config: stable sort order by line number
7
+ - validate_content_for_edit_lane: function-local advisories stay out of the deny payload
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -19,19 +20,22 @@ if _BLOCKING_DIRECTORY not in sys.path:
19
20
  if _HOOKS_DIRECTORY not in sys.path:
20
21
  sys.path.insert(0, _HOOKS_DIRECTORY)
21
22
 
22
- from code_rules_constants_config import ( # noqa: E402
23
- check_constants_outside_config,
24
- check_constants_outside_config_advisory,
25
- )
23
+ from code_rules_constants_config import check_constants_outside_config # noqa: E402
24
+ from code_rules_enforcer import validate_content_for_edit_lane # noqa: E402
26
25
  from code_rules_path_utils import is_config_file # noqa: E402
27
26
 
28
27
  code_rules_enforcer = SimpleNamespace(
29
28
  check_constants_outside_config=check_constants_outside_config,
30
- check_constants_outside_config_advisory=check_constants_outside_config_advisory,
31
29
  is_config_file=is_config_file,
32
30
  )
33
31
 
34
32
  PRODUCTION_FILE_PATH = "packages/claude-dev-env/src/example.py"
33
+ FUNCTION_LOCAL_UPPER_SNAKE_SOURCE = (
34
+ "def fetch_data():\n"
35
+ " MAX_RETRIES = 3\n"
36
+ " for attempt in range(MAX_RETRIES):\n"
37
+ " pass\n"
38
+ )
35
39
 
36
40
 
37
41
  def test_should_return_false_for_filename_named_config_dot_py() -> None:
@@ -63,22 +67,22 @@ def test_should_return_true_for_config_dir_backslash() -> None:
63
67
 
64
68
 
65
69
  def test_should_produce_advisory_not_blocking_for_function_local_upper_snake() -> None:
66
- source = (
67
- "def fetch_data():\n"
68
- " MAX_RETRIES = 3\n"
69
- " for attempt in range(MAX_RETRIES):\n"
70
- " pass\n"
71
- )
72
- advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
73
- source, PRODUCTION_FILE_PATH
74
- )
75
70
  blocking_issues = code_rules_enforcer.check_constants_outside_config(
76
- source, PRODUCTION_FILE_PATH
71
+ FUNCTION_LOCAL_UPPER_SNAKE_SOURCE, PRODUCTION_FILE_PATH
77
72
  )
78
- assert any("MAX_RETRIES" in issue for issue in advisory_issues)
79
73
  assert not any("MAX_RETRIES" in issue for issue in blocking_issues)
80
74
 
81
75
 
76
+ def test_function_local_upper_snake_advisory_never_reaches_the_deny_payload() -> None:
77
+ edit_lane_issues = validate_content_for_edit_lane(
78
+ FUNCTION_LOCAL_UPPER_SNAKE_SOURCE, PRODUCTION_FILE_PATH
79
+ )
80
+ assert not any("MAX_RETRIES" in issue for issue in edit_lane_issues), (
81
+ f"Expected function-local MAX_RETRIES to stay out of the deny payload, "
82
+ f"got: {edit_lane_issues}"
83
+ )
84
+
85
+
82
86
  def test_should_produce_blocking_for_module_level_upper_snake_outside_config() -> None:
83
87
  source = "MAX_RETRIES = 3\n\ndef fetch_data():\n pass\n"
84
88
  blocking_issues = code_rules_enforcer.check_constants_outside_config(
@@ -87,6 +91,15 @@ def test_should_produce_blocking_for_module_level_upper_snake_outside_config() -
87
91
  assert any("MAX_RETRIES" in issue for issue in blocking_issues)
88
92
 
89
93
 
94
+ def test_module_level_upper_snake_still_reaches_the_deny_payload() -> None:
95
+ source = "MAXIMUM_RETRIES = 3\n\ndef fetch_data() -> int:\n return MAXIMUM_RETRIES\n"
96
+ edit_lane_issues = validate_content_for_edit_lane(source, PRODUCTION_FILE_PATH)
97
+ assert any(
98
+ "MAXIMUM_RETRIES" in issue and "move to config/" in issue
99
+ for issue in edit_lane_issues
100
+ ), f"Expected module-level MAXIMUM_RETRIES to keep denying the write, got: {edit_lane_issues}"
101
+
102
+
90
103
  def test_should_produce_stable_ordering_sorted_by_line_number() -> None:
91
104
  source = (
92
105
  "ALPHA_CONSTANT = 1\n"
@@ -36,6 +36,8 @@ from code_rules_enforcer_test_support import ( # noqa: E402
36
36
  run_write_entrypoint,
37
37
  )
38
38
 
39
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
40
+
39
41
  SHARED_HELPER_SOURCE = (
40
42
  "import re\n"
41
43
  "\n"
@@ -0,0 +1,118 @@
1
+ """Behavior tests for code_rules_enforcer's MultiEdit reconstruction.
2
+
3
+ A MultiEdit payload carries an ``edits`` list rather than a single
4
+ ``old_string``/``new_string`` pair. These tests prove the enforcer reconstructs
5
+ the whole post-edit file from every entry in that list — not only the first —
6
+ so a violation introduced by the second or later edit is denied exactly like
7
+ one introduced by the first.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ import pytest
17
+
18
+ from code_rules_enforcer import main
19
+ from code_rules_enforcer_test_support import run_serialized_payload_entrypoint
20
+
21
+ _TWO_FUNCTION_SOURCE = (
22
+ "def first_helper() -> int:\n return 1\n\n\ndef second_helper() -> int:\n return 2\n"
23
+ )
24
+
25
+
26
+ def _production_directory(tmp_path: Path) -> Path:
27
+ """Return a sibling directory whose path carries no test-name substring.
28
+
29
+ ``code_rules_enforcer``'s own path-based exemptions treat any path segment
30
+ matching a test-name pattern as a test file, and pytest's ``tmp_path``
31
+ embeds the test function's name, so scanning directly under ``tmp_path``
32
+ would be silently exempted. A sibling directory named after ordinary
33
+ production code sidesteps that.
34
+ """
35
+ production_directory = tmp_path.parent / "multi-edit-prod"
36
+ production_directory.mkdir(exist_ok=True)
37
+ return production_directory
38
+
39
+
40
+ def _run_multi_edit(
41
+ file_path: str,
42
+ all_edits: list[dict[str, str]],
43
+ multi_edit_payload: Callable[[str, list[dict[str, str]]], str],
44
+ ) -> str:
45
+ stdout_text, _exit_code = run_serialized_payload_entrypoint(
46
+ main, multi_edit_payload(file_path, all_edits)
47
+ )
48
+ return stdout_text
49
+
50
+
51
+ def test_violation_in_second_edit_is_denied(
52
+ tmp_path: Path,
53
+ monkeypatch: pytest.MonkeyPatch,
54
+ multi_edit_payload: Callable[[str, list[dict[str, str]]], str],
55
+ ) -> None:
56
+ """A library print() introduced only by the second edit is caught and denied."""
57
+ monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
58
+ target_file = _production_directory(tmp_path) / "services.py"
59
+ target_file.write_text(_TWO_FUNCTION_SOURCE, encoding="utf-8")
60
+
61
+ stdout_text = _run_multi_edit(
62
+ str(target_file),
63
+ [
64
+ {"old_string": "return 1", "new_string": "return 11"},
65
+ {
66
+ "old_string": " return 2",
67
+ "new_string": " print(2)\n return 2",
68
+ },
69
+ ],
70
+ multi_edit_payload,
71
+ )
72
+
73
+ assert stdout_text.strip(), "expected a deny payload, got no output"
74
+ deny_payload = json.loads(stdout_text)
75
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
76
+ deny_reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"].lower()
77
+ assert "print" in deny_reason
78
+
79
+
80
+ def test_multi_edit_on_a_missing_target_is_allowed(
81
+ tmp_path: Path, multi_edit_payload: Callable[[str, list[dict[str, str]]], str]
82
+ ) -> None:
83
+ """A MultiEdit naming a target file that does not exist on disk is not denied.
84
+
85
+ The reconstruction has no prior content to read, so it returns no
86
+ validatable view rather than crashing or denying a payload it cannot judge.
87
+ """
88
+ missing_target = _production_directory(tmp_path) / "not_on_disk.py"
89
+
90
+ stdout_text = _run_multi_edit(
91
+ str(missing_target),
92
+ [{"old_string": "return 1", "new_string": "return 11"}],
93
+ multi_edit_payload,
94
+ )
95
+
96
+ assert stdout_text.strip() == ""
97
+
98
+
99
+ def test_clean_multi_edit_is_allowed(
100
+ tmp_path: Path,
101
+ monkeypatch: pytest.MonkeyPatch,
102
+ multi_edit_payload: Callable[[str, list[dict[str, str]]], str],
103
+ ) -> None:
104
+ """A MultiEdit whose reconstructed file carries no violation is allowed."""
105
+ monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
106
+ target_file = _production_directory(tmp_path) / "services.py"
107
+ target_file.write_text(_TWO_FUNCTION_SOURCE, encoding="utf-8")
108
+
109
+ stdout_text = _run_multi_edit(
110
+ str(target_file),
111
+ [
112
+ {"old_string": "return 1", "new_string": "return 11"},
113
+ {"old_string": "return 2", "new_string": "return 22"},
114
+ ],
115
+ multi_edit_payload,
116
+ )
117
+
118
+ assert stdout_text.strip() == ""
@@ -23,6 +23,8 @@ from code_rules_enforcer import ( # noqa: E402
23
23
  validate_content_for_full_gate as validate_content,
24
24
  )
25
25
 
26
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
27
+
26
28
 
27
29
  PRODUCTION_FILE_PATH = "packages/app/services.py"
28
30
 
@@ -26,6 +26,8 @@ from code_rules_enforcer_test_support import ( # noqa: E402
26
26
  run_serialized_payload_entrypoint,
27
27
  )
28
28
 
29
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
30
+
29
31
  _ENFORCER_SCRIPT_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
30
32
 
31
33
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"