claude-dev-env 2.30.1 → 2.31.1
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.
- package/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +12 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +63 -16
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
import tempfile
|
|
@@ -168,8 +169,18 @@ def test_should_return_true_for_root_anchored_tmp_and_temp(
|
|
|
168
169
|
raw_path: str,
|
|
169
170
|
monkeypatch: pytest.MonkeyPatch,
|
|
170
171
|
) -> None:
|
|
171
|
-
"""B4: classifier returns True for root-anchored /tmp and /temp paths.
|
|
172
|
+
"""B4: classifier returns True for root-anchored /tmp and /temp paths.
|
|
173
|
+
|
|
174
|
+
A Windows drive-letter literal is absolute only on Windows. On POSIX,
|
|
175
|
+
os.path.abspath() joins a non-absolute path with the process cwd, so a
|
|
176
|
+
drive-letter case's pass or fail state depends on whatever directory the
|
|
177
|
+
runner happens to sit in. Skip it on a host that does not treat the
|
|
178
|
+
literal as absolute, so the assertion checks its own precondition rather
|
|
179
|
+
than the runner's ambient cwd.
|
|
180
|
+
"""
|
|
172
181
|
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
182
|
+
if not os.path.isabs(raw_path):
|
|
183
|
+
pytest.skip(f"{raw_path!r} is not absolute on this host")
|
|
173
184
|
assert is_ephemeral_script_path(raw_path) is True
|
|
174
185
|
|
|
175
186
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Repository-root exemption tests for code_rules_enforcer and the classifier.
|
|
2
|
+
|
|
3
|
+
A path inside the session's repository root is never ephemeral, whatever
|
|
4
|
+
directory the repository itself is checked out under. These tests cover the
|
|
5
|
+
classifier directly and the enforcer's real PreToolUse Write path, plus the
|
|
6
|
+
guard cases a too-broad fix could break: an absent cwd, a cwd that covers no
|
|
7
|
+
repository, and an unrelated sibling worktree that merely shares a name
|
|
8
|
+
prefix under /tmp.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
|
|
16
|
+
from blocking.code_rules_enforcer import main as enforcer_main
|
|
17
|
+
from blocking.code_rules_shared import is_ephemeral_script_path
|
|
18
|
+
from code_rules_enforcer_test_support import run_serialized_payload_entrypoint
|
|
19
|
+
|
|
20
|
+
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_write_payload_with_cwd(file_path: str, content: str, cwd: str) -> str:
|
|
24
|
+
"""Build the JSON payload for a Write tool call that also carries a cwd."""
|
|
25
|
+
return json.dumps(
|
|
26
|
+
{
|
|
27
|
+
"tool_name": "Write",
|
|
28
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
29
|
+
"cwd": cwd,
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_main_with_write_payload_and_cwd(
|
|
35
|
+
file_path: str,
|
|
36
|
+
content: str,
|
|
37
|
+
cwd: str,
|
|
38
|
+
) -> tuple[str, int]:
|
|
39
|
+
"""Drive enforcer_main through its stdin entry point for a Write payload carrying cwd."""
|
|
40
|
+
captured_stdout, exit_code = run_serialized_payload_entrypoint(
|
|
41
|
+
enforcer_main, _build_write_payload_with_cwd(file_path, content, cwd)
|
|
42
|
+
)
|
|
43
|
+
return captured_stdout, int(exit_code or 0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_should_return_false_for_file_inside_repository_root_under_tmp() -> None:
|
|
47
|
+
"""R1: a path inside repository_root is never ephemeral, even under /tmp."""
|
|
48
|
+
repository_root = "/tmp/pr-alpha"
|
|
49
|
+
file_path = "/tmp/pr-alpha/hooks/blocking/orders.py"
|
|
50
|
+
assert is_ephemeral_script_path(file_path, repository_root) is False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_return_true_for_sibling_directory_outside_repository_root() -> None:
|
|
54
|
+
"""R2: a /tmp sibling that merely shares repository_root's name prefix stays ephemeral."""
|
|
55
|
+
repository_root = "/tmp/pr-alpha"
|
|
56
|
+
sibling_file_path = "/tmp/pr-alpha-sibling/hooks/orders.py"
|
|
57
|
+
assert is_ephemeral_script_path(sibling_file_path, repository_root) is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_should_return_true_when_repository_root_does_not_cover_target() -> None:
|
|
61
|
+
"""R3: an unrelated cwd must not blanket-exempt a /tmp target outside it."""
|
|
62
|
+
unrelated_repository_root = "/tmp/scratch-workspace"
|
|
63
|
+
scratch_target = "/tmp/scratch-elsewhere/notes.py"
|
|
64
|
+
assert is_ephemeral_script_path(scratch_target, unrelated_repository_root) is True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_deny_pretooluse_target_inside_repository_root_under_tmp() -> None:
|
|
68
|
+
"""B22: a production file inside a repository checked out under /tmp is gated for real."""
|
|
69
|
+
repository_root = "/tmp/pr-alpha"
|
|
70
|
+
file_path = f"{repository_root}/hooks/blocking/orders.py"
|
|
71
|
+
captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
|
|
72
|
+
file_path,
|
|
73
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
74
|
+
repository_root,
|
|
75
|
+
)
|
|
76
|
+
assert "deny" in captured_stdout.lower(), (
|
|
77
|
+
f"enforcer must gate a repository file under /tmp, got exit_code={exit_code}, "
|
|
78
|
+
f"stdout={captured_stdout!r}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_stay_exempt_for_scratch_outside_repository_root() -> None:
|
|
83
|
+
"""B23: a genuine scratch file outside the repository stays exempt."""
|
|
84
|
+
repository_root = "/tmp/pr-alpha"
|
|
85
|
+
scratch_target = "/tmp/scratch/one_off.py"
|
|
86
|
+
captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
|
|
87
|
+
scratch_target,
|
|
88
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
89
|
+
repository_root,
|
|
90
|
+
)
|
|
91
|
+
assert exit_code == 0
|
|
92
|
+
assert "deny" not in captured_stdout.lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_should_stay_exempt_when_working_directory_covers_no_repository() -> None:
|
|
96
|
+
"""B24: a cwd that covers no repository must not exempt an unrelated /tmp target."""
|
|
97
|
+
unrelated_working_directory = "/tmp/scratch-workspace"
|
|
98
|
+
scratch_target = "/tmp/scratch-elsewhere/notes.py"
|
|
99
|
+
captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
|
|
100
|
+
scratch_target,
|
|
101
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
102
|
+
unrelated_working_directory,
|
|
103
|
+
)
|
|
104
|
+
assert exit_code == 0
|
|
105
|
+
assert "deny" not in captured_stdout.lower()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_stay_exempt_for_unrelated_worktree_sibling_under_tmp() -> None:
|
|
109
|
+
"""B25: a sibling worktree sharing repository_root's name prefix stays exempt."""
|
|
110
|
+
repository_root = "/tmp/pr-alpha"
|
|
111
|
+
unrelated_sibling_target = "/tmp/pr-alpha-sibling/hooks/orders.py"
|
|
112
|
+
captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
|
|
113
|
+
unrelated_sibling_target,
|
|
114
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
115
|
+
repository_root,
|
|
116
|
+
)
|
|
117
|
+
assert exit_code == 0
|
|
118
|
+
assert "deny" not in captured_stdout.lower()
|
|
@@ -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() == ""
|
|
@@ -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"
|