claude-dev-env 2.30.0 → 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.
- package/.agents/agents/pr-description-writer.md +1 -1
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- 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 +11 -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_enforcer.py +59 -36
- 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 +263 -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 +62 -8
- 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 +57 -14
- 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 +15 -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_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_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -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_code_rules_validation_phase.py +51 -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 +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -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 +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- 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 +39 -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/shell_substitution_blocker_constants.py +4 -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/task_tool_prompt_constants.py +13 -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 -9
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -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 +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -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 +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- 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/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- 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 +2 -0
- 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/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -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"
|
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from types import SimpleNamespace
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
9
11
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
10
12
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
11
13
|
if _BLOCKING_DIRECTORY not in sys.path:
|
|
@@ -103,7 +105,19 @@ def test_scan_function_body_constants_does_not_flag_module_level() -> None:
|
|
|
103
105
|
assert advisory_issues == []
|
|
104
106
|
|
|
105
107
|
|
|
106
|
-
def
|
|
108
|
+
def _advisory_stderr(source: str, capsys: pytest.CaptureFixture[str]) -> str:
|
|
109
|
+
"""Run the advisory check over source and hand back what it wrote to stderr.
|
|
110
|
+
|
|
111
|
+
The check returns nothing, so stderr is the only place a finding shows up.
|
|
112
|
+
"""
|
|
113
|
+
code_rules_enforcer.check_constants_outside_config_advisory(
|
|
114
|
+
source,
|
|
115
|
+
"example_module.py",
|
|
116
|
+
)
|
|
117
|
+
return capsys.readouterr().err
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_advisory_should_not_flag_class_attribute_after_method_def(capsys: pytest.CaptureFixture[str]) -> None:
|
|
107
121
|
source_with_class_attribute_after_method = (
|
|
108
122
|
"class ExampleModel:\n"
|
|
109
123
|
" def method_a(self) -> None:\n"
|
|
@@ -111,49 +125,40 @@ def test_advisory_should_not_flag_class_attribute_after_method_def() -> None:
|
|
|
111
125
|
"\n"
|
|
112
126
|
" TABLE_NAME = \"example\"\n"
|
|
113
127
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"example_module.py",
|
|
117
|
-
)
|
|
118
|
-
assert advisory_issues == [], (
|
|
128
|
+
flagged_names = _advisory_stderr(source_with_class_attribute_after_method, capsys)
|
|
129
|
+
assert flagged_names == "", (
|
|
119
130
|
"Class-level TABLE_NAME attribute must not be flagged as function-local"
|
|
120
131
|
)
|
|
121
132
|
|
|
122
133
|
|
|
123
|
-
def test_advisory_should_still_flag_actual_method_body_constant() -> None:
|
|
134
|
+
def test_advisory_should_still_flag_actual_method_body_constant(capsys: pytest.CaptureFixture[str]) -> None:
|
|
124
135
|
source_with_method_body_constant = (
|
|
125
136
|
"class ExampleModel:\n"
|
|
126
137
|
" def method_a(self) -> None:\n"
|
|
127
138
|
" MAXIMUM_RETRIES = 3\n"
|
|
128
139
|
" return None\n"
|
|
129
140
|
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"
|
|
133
|
-
|
|
134
|
-
assert len(advisory_issues) == 1, (
|
|
135
|
-
"Method-body UPPER_SNAKE constant must still surface as advisory"
|
|
141
|
+
flagged_names = _advisory_stderr(source_with_method_body_constant, capsys)
|
|
142
|
+
assert "MAXIMUM_RETRIES" in flagged_names, (
|
|
143
|
+
f"Method-body UPPER_SNAKE constant must still surface as advisory, "
|
|
144
|
+
f"got: {flagged_names!r}"
|
|
136
145
|
)
|
|
137
|
-
assert "MAXIMUM_RETRIES" in advisory_issues[0]
|
|
138
146
|
|
|
139
147
|
|
|
140
|
-
def test_advisory_should_flag_annotated_function_body_constant() -> None:
|
|
148
|
+
def test_advisory_should_flag_annotated_function_body_constant(capsys: pytest.CaptureFixture[str]) -> None:
|
|
141
149
|
source_with_annotated_function_body_constant = (
|
|
142
150
|
"def example_function() -> None:\n"
|
|
143
151
|
" MAXIMUM_RETRIES: int = 3\n"
|
|
144
152
|
" return None\n"
|
|
145
153
|
)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
154
|
+
flagged_names = _advisory_stderr(source_with_annotated_function_body_constant, capsys)
|
|
155
|
+
assert "MAXIMUM_RETRIES" in flagged_names, (
|
|
156
|
+
f"Annotated function-body UPPER_SNAKE constant (PEP 526) must surface as advisory, "
|
|
157
|
+
f"got: {flagged_names!r}"
|
|
149
158
|
)
|
|
150
|
-
assert len(advisory_issues) == 1, (
|
|
151
|
-
"Annotated function-body UPPER_SNAKE constant (PEP 526) must surface as advisory"
|
|
152
|
-
)
|
|
153
|
-
assert "MAXIMUM_RETRIES" in advisory_issues[0]
|
|
154
159
|
|
|
155
160
|
|
|
156
|
-
def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
|
|
161
|
+
def test_advisory_should_flag_outer_constants_after_nested_def(capsys: pytest.CaptureFixture[str]) -> None:
|
|
157
162
|
source_with_nested_def = (
|
|
158
163
|
"def outer():\n"
|
|
159
164
|
" OUTER_CONST = 1\n"
|
|
@@ -161,11 +166,7 @@ def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
|
|
|
161
166
|
" INNER_CONST = 2\n"
|
|
162
167
|
" ANOTHER_OUTER = 3\n"
|
|
163
168
|
)
|
|
164
|
-
|
|
165
|
-
source_with_nested_def,
|
|
166
|
-
"example_module.py",
|
|
167
|
-
)
|
|
168
|
-
flagged_names = " ".join(advisory_issues)
|
|
169
|
+
flagged_names = _advisory_stderr(source_with_nested_def, capsys)
|
|
169
170
|
assert "OUTER_CONST" in flagged_names, (
|
|
170
171
|
"OUTER_CONST before nested def must be flagged"
|
|
171
172
|
)
|
|
@@ -36,6 +36,8 @@ code_rules_enforcer = SimpleNamespace(
|
|
|
36
36
|
validate_content=validate_content,
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
40
|
+
|
|
39
41
|
|
|
40
42
|
def _oversized_function_source(name: str) -> str:
|
|
41
43
|
body_line_count = code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
|
|
@@ -33,6 +33,8 @@ from code_rules_gate_parts.tests._repo_test_helpers import ( # noqa: E402
|
|
|
33
33
|
write_commit_and_stage_change,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
37
|
+
|
|
36
38
|
|
|
37
39
|
PRODUCTION_FILE_PATH = "packages/app/services.py"
|
|
38
40
|
ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
|
|
@@ -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
|
PASS_THROUGH_ALIAS_SOURCE = (
|
|
40
42
|
"def find_bare_path_segments(content: str) -> set[str]:\n"
|
|
41
43
|
" return {part for part in content.split() if part}\n"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ast
|
|
5
6
|
import contextlib
|
|
6
7
|
import importlib
|
|
7
8
|
import importlib.util
|
|
@@ -10,6 +11,7 @@ import re
|
|
|
10
11
|
import shutil
|
|
11
12
|
import sys
|
|
12
13
|
import tempfile
|
|
14
|
+
import textwrap
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from types import ModuleType
|
|
15
17
|
from unittest.mock import DEFAULT, MagicMock, patch
|
|
@@ -279,3 +281,52 @@ def test_gate_running_selects_the_full_gate(tmp_path: Path) -> None:
|
|
|
279
281
|
assert scoped_violations is not None
|
|
280
282
|
blocking_violations, _advisory_violations = scoped_violations
|
|
281
283
|
assert any("9999" in each_issue for each_issue in blocking_violations)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
ALL_CHECK_FUNCTION_NAME_PREFIXES = ("check_", "advise_")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_names_called_in(enforcer_function_name: str) -> set[str]:
|
|
290
|
+
"""Return the check-function names called directly inside a phase router.
|
|
291
|
+
|
|
292
|
+
Parses the router's own source and collects every direct call to a
|
|
293
|
+
module-level name beginning with a check prefix, so a roster constant can
|
|
294
|
+
assert against the calls production really makes rather than against a
|
|
295
|
+
second hand-written literal.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
enforcer_function_name: The attribute name of the router on the
|
|
299
|
+
enforcer module, such as ``_python_full_gate_only_issues``.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The set of check-function names the router calls directly.
|
|
303
|
+
"""
|
|
304
|
+
router_source = textwrap.dedent(
|
|
305
|
+
inspect.getsource(getattr(code_rules_enforcer, enforcer_function_name))
|
|
306
|
+
)
|
|
307
|
+
return {
|
|
308
|
+
each_node.func.id
|
|
309
|
+
for each_node in ast.walk(ast.parse(router_source))
|
|
310
|
+
if isinstance(each_node, ast.Call)
|
|
311
|
+
and isinstance(each_node.func, ast.Name)
|
|
312
|
+
and each_node.func.id.startswith(ALL_CHECK_FUNCTION_NAME_PREFIXES)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_full_gate_only_roster_matches_the_checks_the_router_calls() -> None:
|
|
317
|
+
"""The roster constant tracks the calls in _python_full_gate_only_issues."""
|
|
318
|
+
assert (
|
|
319
|
+
_check_names_called_in("_python_full_gate_only_issues")
|
|
320
|
+
== validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
|
|
321
|
+
), "ALL_FULL_GATE_ONLY_CHECK_NAMES must name exactly the router's own calls"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_hook_infrastructure_edit_lane_roster_matches_the_checks_the_router_calls() -> None:
|
|
325
|
+
"""The roster constant tracks the always-run calls in the hook-infrastructure router."""
|
|
326
|
+
all_always_run_check_names = _check_names_called_in(
|
|
327
|
+
"_hook_infrastructure_blocking_issues"
|
|
328
|
+
) - validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
|
|
329
|
+
assert (
|
|
330
|
+
all_always_run_check_names
|
|
331
|
+
== validation_phase_constants.ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES
|
|
332
|
+
), "ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES must name exactly the router's own calls"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for the Codex apply_patch path-only target resolver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from codex_apply_patch import (
|
|
10
|
+
CODEX_ADD_OPERATION,
|
|
11
|
+
CodexPatchError,
|
|
12
|
+
codex_patch_operation_targets,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_codex_patch_operation_targets_names_every_section_without_reading_content(
|
|
17
|
+
tmp_path: Path, three_operation_patch: str
|
|
18
|
+
) -> None:
|
|
19
|
+
"""The path-only helper resolves every section's target without any disk read.
|
|
20
|
+
|
|
21
|
+
Unlike ``parse_codex_apply_patch``, it never reads prior file content, so a
|
|
22
|
+
caller that only needs each target path is not tripped up by an unreadable
|
|
23
|
+
prior file.
|
|
24
|
+
"""
|
|
25
|
+
all_targets = codex_patch_operation_targets(three_operation_patch, str(tmp_path))
|
|
26
|
+
|
|
27
|
+
all_target_names = {
|
|
28
|
+
(each_operation, str(each_path).rsplit("/", 1)[-1])
|
|
29
|
+
for each_operation, each_path in all_targets
|
|
30
|
+
}
|
|
31
|
+
assert all_target_names == {
|
|
32
|
+
("update", "updated.py"),
|
|
33
|
+
(CODEX_ADD_OPERATION, "added.py"),
|
|
34
|
+
("delete", "deleted.py"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_codex_patch_operation_targets_rejects_path_traversal(tmp_path: Path) -> None:
|
|
39
|
+
"""A patch path that escapes the working directory is rejected before any read."""
|
|
40
|
+
escaping_patch = "*** Begin Patch\n*** Add File: ../outside.py\n+content\n*** End Patch"
|
|
41
|
+
|
|
42
|
+
with pytest.raises(CodexPatchError):
|
|
43
|
+
codex_patch_operation_targets(escaping_patch, str(tmp_path))
|
|
@@ -205,6 +205,74 @@ def test_extract_payload_text_returns_empty_for_unknown_tool() -> None:
|
|
|
205
205
|
assert extracted == ("", "")
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def test_every_applicable_tool_name_reads_payload_text() -> None:
|
|
209
|
+
"""extract_payload_text reads content for every name the shared constant lists."""
|
|
210
|
+
for each_applicable_tool_name in hook_module.ALL_APPLICABLE_TOOL_NAMES:
|
|
211
|
+
_file_path, scanned_text = extract_payload_text(
|
|
212
|
+
each_applicable_tool_name,
|
|
213
|
+
{
|
|
214
|
+
"file_path": "foo.py",
|
|
215
|
+
"content": "abc",
|
|
216
|
+
"new_string": "abc",
|
|
217
|
+
"edits": [{"old_string": "a", "new_string": "abc"}],
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
assert scanned_text, f"{each_applicable_tool_name} must read some scanned text"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_extract_payload_text_reads_every_multi_edit_new_string() -> None:
|
|
224
|
+
extracted = extract_payload_text(
|
|
225
|
+
"MultiEdit",
|
|
226
|
+
{
|
|
227
|
+
"file_path": "foo.py",
|
|
228
|
+
"edits": [
|
|
229
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
230
|
+
{"old_string": "b = 2", "new_string": "def force_rmtree(target_path):\n pass"},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
assert extracted[0] == "foo.py"
|
|
235
|
+
assert "force_rmtree" in extracted[1]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_main_blocks_trio_copy_introduced_by_second_multi_edit() -> None:
|
|
239
|
+
"""A helper definition in the second edit of a MultiEdit is caught, not only the first."""
|
|
240
|
+
stdout_text, exit_code = _run_hook(
|
|
241
|
+
{
|
|
242
|
+
"tool_name": "MultiEdit",
|
|
243
|
+
"tool_input": {
|
|
244
|
+
"file_path": "shared_utils/samsung_utils/cert_failure_processor/parser.py",
|
|
245
|
+
"edits": [
|
|
246
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
247
|
+
{"old_string": "b = 2", "new_string": COPIED_TRIO},
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
assert exit_code == 0
|
|
253
|
+
response_payload = json.loads(stdout_text)
|
|
254
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
255
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
256
|
+
assert "duplicate-rmtree-helper" in decision_block["permissionDecisionReason"]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_main_allows_clean_multi_edit() -> None:
|
|
260
|
+
stdout_text, exit_code = _run_hook(
|
|
261
|
+
{
|
|
262
|
+
"tool_name": "MultiEdit",
|
|
263
|
+
"tool_input": {
|
|
264
|
+
"file_path": "shared_utils/samsung_utils/cert_failure_processor/parser.py",
|
|
265
|
+
"edits": [
|
|
266
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
267
|
+
{"old_string": "b = 2", "new_string": "b = 22"},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
assert exit_code == 0
|
|
273
|
+
assert stdout_text == ""
|
|
274
|
+
|
|
275
|
+
|
|
208
276
|
def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
|
|
209
277
|
captured_stdout = io.StringIO()
|
|
210
278
|
captured_stderr = io.StringIO()
|
|
@@ -193,6 +193,16 @@ def test_written_content_reads_edit_new_string() -> None:
|
|
|
193
193
|
assert written_content("Edit", edit_input) == "edited body"
|
|
194
194
|
|
|
195
195
|
|
|
196
|
+
def test_written_content_reads_every_multi_edit_new_string() -> None:
|
|
197
|
+
multi_edit_input = {
|
|
198
|
+
"edits": [
|
|
199
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
200
|
+
{"old_string": "b = 2", "new_string": _OVERSTATED_MESSAGE_MODULE},
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
assert _OVERSTATED_MESSAGE_MODULE in written_content("MultiEdit", multi_edit_input)
|
|
204
|
+
|
|
205
|
+
|
|
196
206
|
def _run_main_with_io(
|
|
197
207
|
input_text: str, *, is_prose_style_enabled: bool = True
|
|
198
208
|
) -> str:
|
|
@@ -248,6 +258,37 @@ def test_main_blocks_overstated_hook_module_edit() -> None:
|
|
|
248
258
|
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
249
259
|
|
|
250
260
|
|
|
261
|
+
def test_main_blocks_overstatement_introduced_by_second_multi_edit() -> None:
|
|
262
|
+
"""An overstated claim in the second edit of a MultiEdit is caught, not only the first."""
|
|
263
|
+
hook_input = {
|
|
264
|
+
"tool_name": "MultiEdit",
|
|
265
|
+
"tool_input": {
|
|
266
|
+
"file_path": "/repo/hooks/hooks_constants/some_blocker_constants.py",
|
|
267
|
+
"edits": [
|
|
268
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
269
|
+
{"old_string": "b = 2", "new_string": _OVERSTATED_MESSAGE_MODULE},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
274
|
+
payload = json.loads(output_text)
|
|
275
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_main_passes_clean_multi_edit() -> None:
|
|
279
|
+
hook_input = {
|
|
280
|
+
"tool_name": "MultiEdit",
|
|
281
|
+
"tool_input": {
|
|
282
|
+
"file_path": "/repo/hooks/hooks_constants/some_blocker_constants.py",
|
|
283
|
+
"edits": [
|
|
284
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
285
|
+
{"old_string": "b = 2", "new_string": _FIXED_MESSAGE_MODULE},
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
290
|
+
|
|
291
|
+
|
|
251
292
|
def test_main_passes_fixed_hook_module_write() -> None:
|
|
252
293
|
hook_input = {
|
|
253
294
|
"tool_name": "Write",
|
|
@@ -13,12 +13,22 @@ _HOOK_DIR = Path(__file__).parent
|
|
|
13
13
|
_HOOKS_DIR = _HOOK_DIR.parent
|
|
14
14
|
|
|
15
15
|
try:
|
|
16
|
-
from pii_payload_scan import
|
|
16
|
+
from pii_payload_scan import (
|
|
17
|
+
evaluate_apply_patch_payload,
|
|
18
|
+
evaluate_post_body_texts,
|
|
19
|
+
evaluate_write_edit_payload,
|
|
20
|
+
)
|
|
17
21
|
except ImportError:
|
|
18
22
|
for each_bootstrap_directory in (str(_HOOK_DIR), str(_HOOKS_DIR)):
|
|
19
23
|
if each_bootstrap_directory not in sys.path:
|
|
20
24
|
sys.path.insert(0, each_bootstrap_directory)
|
|
21
|
-
from pii_payload_scan import
|
|
25
|
+
from pii_payload_scan import (
|
|
26
|
+
evaluate_apply_patch_payload,
|
|
27
|
+
evaluate_post_body_texts,
|
|
28
|
+
evaluate_write_edit_payload,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
22
32
|
|
|
23
33
|
_ALLOW_SLUG = "AllowOwner/allow-repo"
|
|
24
34
|
_OTHER_SLUG = "OtherOwner/other-repo"
|
|
@@ -166,3 +176,56 @@ def test_allowlisted_value_under_missing_nested_parent_is_allowed(
|
|
|
166
176
|
nested_target = repository_root / "new_dir" / "deeper" / "notes.md"
|
|
167
177
|
assert not nested_target.parent.exists()
|
|
168
178
|
assert _write_deny_reason(nested_target, allowed_value) is None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_apply_patch_add_with_pii_is_denied(tmp_path: Path) -> None:
|
|
182
|
+
repository_root = tmp_path / "repo"
|
|
183
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
184
|
+
deny_reason = evaluate_apply_patch_payload(
|
|
185
|
+
{
|
|
186
|
+
"command": (
|
|
187
|
+
"*** Begin Patch\n"
|
|
188
|
+
"*** Add File: notes.md\n"
|
|
189
|
+
f"+Reach me at {_assembled_fixture_email()}\n"
|
|
190
|
+
"*** End Patch"
|
|
191
|
+
)
|
|
192
|
+
},
|
|
193
|
+
hook_payload={"cwd": str(repository_root)},
|
|
194
|
+
)
|
|
195
|
+
assert deny_reason is not None
|
|
196
|
+
assert "email" in deny_reason
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_apply_patch_add_with_clean_content_is_allowed(tmp_path: Path) -> None:
|
|
200
|
+
repository_root = tmp_path / "repo"
|
|
201
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
202
|
+
deny_reason = evaluate_apply_patch_payload(
|
|
203
|
+
{
|
|
204
|
+
"command": (
|
|
205
|
+
"*** Begin Patch\n"
|
|
206
|
+
"*** Add File: notes.md\n"
|
|
207
|
+
"+nothing sensitive here\n"
|
|
208
|
+
"*** End Patch"
|
|
209
|
+
)
|
|
210
|
+
},
|
|
211
|
+
hook_payload={"cwd": str(repository_root)},
|
|
212
|
+
)
|
|
213
|
+
assert deny_reason is None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_apply_patch_payload_accepts_the_tool_input_keyword(tmp_path: Path) -> None:
|
|
217
|
+
"""evaluate_apply_patch_payload's first parameter is named all_tool_input."""
|
|
218
|
+
repository_root = tmp_path / "repo"
|
|
219
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
220
|
+
deny_reason = evaluate_apply_patch_payload(
|
|
221
|
+
all_tool_input={
|
|
222
|
+
"command": (
|
|
223
|
+
"*** Begin Patch\n"
|
|
224
|
+
"*** Add File: notes.md\n"
|
|
225
|
+
"+nothing sensitive here\n"
|
|
226
|
+
"*** End Patch"
|
|
227
|
+
)
|
|
228
|
+
},
|
|
229
|
+
hook_payload={"cwd": str(repository_root)},
|
|
230
|
+
)
|
|
231
|
+
assert deny_reason is None
|