claude-dev-env 2.29.0 → 2.30.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 +21 -6
- package/.agents/agents/reference/README.md +6 -0
- package/.agents/agents/reference/pr-description-illustrative-voice.md +45 -0
- package/.agents/agents/reference/pr-description-verification.md +19 -0
- package/.agents/agents/test_pr_description_writer_style.py +47 -0
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/_shared/pr-loop/code-rules-gate.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +8 -8
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
- package/docs/CODE_RULES.md +10 -0
- package/hooks/blocking/code_rules_enforcer.py +435 -318
- package/hooks/blocking/shell_substitution_blocker.py +106 -30
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +14 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +117 -22
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +3 -3
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +117 -38
- package/hooks/blocking/test_code_rules_enforcer_output_snapshot.py +121 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +17 -15
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -2
- package/hooks/blocking/test_code_rules_validation_phase.py +332 -0
- package/hooks/blocking/test_fable_spawn_gate.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +12 -0
- package/hooks/hooks_constants/validation_phase_constants.py +34 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/config/hook_timing_harness_constants.py +16 -0
- package/hooks/validators/hook_timing_harness.py +287 -0
- package/hooks/validators/test_hook_timing_harness.py +251 -0
- package/package.json +1 -1
- 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,332 @@
|
|
|
1
|
+
"""Tests for the validate_content edit-lane and full-gate phase split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import contextlib
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.util
|
|
9
|
+
import inspect
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import textwrap
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
from unittest.mock import DEFAULT, MagicMock, patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
|
|
23
|
+
"""Load and execute the module found at module_path, under module_name."""
|
|
24
|
+
specification = importlib.util.spec_from_file_location(module_name, module_path)
|
|
25
|
+
assert specification is not None
|
|
26
|
+
assert specification.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(specification)
|
|
28
|
+
specification.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
33
|
+
_HOOKS_DIRECTORY = _BLOCKING_DIRECTORY.parent
|
|
34
|
+
_PACKAGE_ROOT = _HOOKS_DIRECTORY.parent
|
|
35
|
+
_PR_LOOP_SCRIPTS_DIRECTORY = str(_PACKAGE_ROOT / "_shared" / "pr-loop" / "scripts")
|
|
36
|
+
|
|
37
|
+
code_rules_enforcer = _load_module_from_path(
|
|
38
|
+
"code_rules_enforcer", _BLOCKING_DIRECTORY / "code_rules_enforcer.py"
|
|
39
|
+
)
|
|
40
|
+
validation_phase_constants = _load_module_from_path(
|
|
41
|
+
"validation_phase_constants",
|
|
42
|
+
_HOOKS_DIRECTORY / "hooks_constants" / "validation_phase_constants.py",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if _PR_LOOP_SCRIPTS_DIRECTORY not in sys.path:
|
|
46
|
+
sys.path.insert(0, _PR_LOOP_SCRIPTS_DIRECTORY)
|
|
47
|
+
enforcer_loading = importlib.import_module("code_rules_gate_parts.enforcer_loading")
|
|
48
|
+
gate_running = importlib.import_module("code_rules_gate_parts.gate_running")
|
|
49
|
+
repo_test_helpers = importlib.import_module("code_rules_gate_parts.tests._repo_test_helpers")
|
|
50
|
+
|
|
51
|
+
PRODUCTION_FILE_PATH = "packages/app/services.py"
|
|
52
|
+
SAMPLE_PYTHON_CONTENT = "def add_one(value: int) -> int:\n return value + 1\n"
|
|
53
|
+
UNKNOWN_PHASE_NAME = "not_a_real_phase"
|
|
54
|
+
HOOK_INFRASTRUCTURE_FILE_PATH = "/repo/packages/claude-dev-env/hooks/blocking/example.py"
|
|
55
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE = (
|
|
56
|
+
"def compute_shipping_total(order_total: float, tax_rate: float) -> float:\n"
|
|
57
|
+
" taxed_total = order_total * (1 + tax_rate)\n"
|
|
58
|
+
" rounded_total = round(taxed_total, 2)\n"
|
|
59
|
+
" return rounded_total\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_all_validation_phases_holds_exactly_the_two_phases() -> None:
|
|
64
|
+
assert validation_phase_constants.ALL_VALIDATION_PHASES == {
|
|
65
|
+
validation_phase_constants.EDIT_LANE_PHASE,
|
|
66
|
+
validation_phase_constants.FULL_GATE_PHASE,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_unknown_phase_raises_value_error_naming_the_rejected_value() -> None:
|
|
71
|
+
with pytest.raises(ValueError, match=UNKNOWN_PHASE_NAME):
|
|
72
|
+
code_rules_enforcer.validate_content_for_phase(
|
|
73
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH, phase=UNKNOWN_PHASE_NAME
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_validate_content_for_phase_requires_phase_keyword() -> None:
|
|
78
|
+
with pytest.raises(TypeError):
|
|
79
|
+
code_rules_enforcer.validate_content_for_phase(SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _full_gate_only_default_kwargs() -> dict[str, object]:
|
|
83
|
+
return {
|
|
84
|
+
each_name: DEFAULT
|
|
85
|
+
for each_name in validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _patched_full_gate_only_checks() -> contextlib.AbstractContextManager[dict[str, MagicMock]]:
|
|
90
|
+
"""Return the patch.multiple context manager for the six full-gate-only checks.
|
|
91
|
+
|
|
92
|
+
::
|
|
93
|
+
|
|
94
|
+
mypy's patch.multiple stub only accepts an ``Any | str`` target, so a
|
|
95
|
+
dynamically loaded ``ModuleType`` target needs a local type: ignore.
|
|
96
|
+
"""
|
|
97
|
+
return patch.multiple( # type: ignore[call-overload] # mypy stub rejects ModuleType targets
|
|
98
|
+
code_rules_enforcer, **_full_gate_only_default_kwargs()
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _configure_full_gate_only_mock_returns(all_mocks: dict[str, MagicMock]) -> None:
|
|
103
|
+
for each_name, each_mock in all_mocks.items():
|
|
104
|
+
each_mock.return_value = None if each_name == "advise_cross_skill_duplicate_helper" else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_edit_lane_calls_none_of_the_six_full_gate_only_checks() -> None:
|
|
108
|
+
with _patched_full_gate_only_checks() as all_mocks:
|
|
109
|
+
_configure_full_gate_only_mock_returns(all_mocks)
|
|
110
|
+
code_rules_enforcer.validate_content_for_edit_lane(
|
|
111
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
112
|
+
)
|
|
113
|
+
for each_name, each_mock in all_mocks.items():
|
|
114
|
+
assert each_mock.call_count == 0, f"edit lane must not call {each_name}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_full_gate_calls_all_six_full_gate_only_checks() -> None:
|
|
118
|
+
with _patched_full_gate_only_checks() as all_mocks:
|
|
119
|
+
_configure_full_gate_only_mock_returns(all_mocks)
|
|
120
|
+
code_rules_enforcer.validate_content_for_full_gate(
|
|
121
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
122
|
+
)
|
|
123
|
+
for each_name, each_mock in all_mocks.items():
|
|
124
|
+
assert each_mock.call_count >= 1, f"full gate must call {each_name}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_both_phases_report_identical_findings_with_the_six_spied_out() -> None:
|
|
128
|
+
with _patched_full_gate_only_checks() as all_mocks:
|
|
129
|
+
_configure_full_gate_only_mock_returns(all_mocks)
|
|
130
|
+
edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
|
|
131
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
132
|
+
)
|
|
133
|
+
full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
134
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
135
|
+
)
|
|
136
|
+
assert edit_lane_issues == full_gate_issues
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_no_caller_should_reach_a_phase_less_validator() -> None:
|
|
140
|
+
assert not hasattr(code_rules_enforcer, "validate_content"), (
|
|
141
|
+
"validate_content must not survive as a callable on the enforcer module"
|
|
142
|
+
)
|
|
143
|
+
enforcer_source = inspect.getsource(code_rules_enforcer)
|
|
144
|
+
assert re.search(r"\bvalidate_content\(", enforcer_source) is None, (
|
|
145
|
+
"no call site in code_rules_enforcer.py may reach a phase-less validator"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_precheck_selects_the_full_gate() -> None:
|
|
150
|
+
precheck_source = inspect.getsource(code_rules_enforcer._run_precheck)
|
|
151
|
+
assert "validate_content_for_full_gate(" in precheck_source, (
|
|
152
|
+
"the --check precheck must run the full-gate verdict"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_codex_patch_issues_selects_the_edit_lane() -> None:
|
|
157
|
+
"""Pin the apply_patch mutation path to the edit-lane verdict."""
|
|
158
|
+
codex_patch_issues_source = inspect.getsource(code_rules_enforcer._codex_patch_issues)
|
|
159
|
+
assert "validate_content_for_edit_lane(" in codex_patch_issues_source, (
|
|
160
|
+
"the apply_patch branch must run the edit-lane verdict"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_forecast_full_file_violations_selects_the_edit_lane() -> None:
|
|
165
|
+
"""Pin the full-file forecast pass to the edit-lane verdict."""
|
|
166
|
+
forecast_source = inspect.getsource(code_rules_enforcer._forecast_full_file_violations)
|
|
167
|
+
assert "validate_content_for_edit_lane(" in forecast_source, (
|
|
168
|
+
"the full-file forecast pass must run the edit-lane verdict"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_report_blocking_violations_selects_the_edit_lane() -> None:
|
|
173
|
+
"""Pin the PreToolUse Write and Edit main path to the edit-lane verdict."""
|
|
174
|
+
report_blocking_violations_source = inspect.getsource(
|
|
175
|
+
code_rules_enforcer._report_blocking_violations
|
|
176
|
+
)
|
|
177
|
+
assert "validate_content_for_edit_lane(" in report_blocking_violations_source, (
|
|
178
|
+
"the PreToolUse Write and Edit main path must run the edit-lane verdict"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _write_scoped_service_file_with_a_committed_baseline(repository_root: Path) -> Path:
|
|
183
|
+
repo_test_helpers.init_repository(repository_root)
|
|
184
|
+
return repo_test_helpers.write_commit_and_stage_change(
|
|
185
|
+
repository_root,
|
|
186
|
+
"service.py",
|
|
187
|
+
"def read_port() -> int:\n return 0\n",
|
|
188
|
+
"def read_port() -> int:\n port_number = 9999\n return port_number\n",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_hook_infrastructure_edit_lane_skips_the_cross_file_duplicate_check() -> None:
|
|
193
|
+
with patch.object(
|
|
194
|
+
code_rules_enforcer, "check_duplicate_function_body_across_files"
|
|
195
|
+
) as mock_check_duplicate_across_files:
|
|
196
|
+
mock_check_duplicate_across_files.return_value = []
|
|
197
|
+
code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
198
|
+
SAMPLE_PYTHON_CONTENT,
|
|
199
|
+
HOOK_INFRASTRUCTURE_FILE_PATH,
|
|
200
|
+
phase=validation_phase_constants.EDIT_LANE_PHASE,
|
|
201
|
+
)
|
|
202
|
+
assert mock_check_duplicate_across_files.call_count == 0, (
|
|
203
|
+
"the hook-infrastructure edit lane must not call the cross-file duplicate-body check"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_hook_infrastructure_full_gate_calls_the_cross_file_duplicate_check() -> None:
|
|
208
|
+
with patch.object(
|
|
209
|
+
code_rules_enforcer, "check_duplicate_function_body_across_files"
|
|
210
|
+
) as mock_check_duplicate_across_files:
|
|
211
|
+
mock_check_duplicate_across_files.return_value = []
|
|
212
|
+
code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
213
|
+
SAMPLE_PYTHON_CONTENT,
|
|
214
|
+
HOOK_INFRASTRUCTURE_FILE_PATH,
|
|
215
|
+
phase=validation_phase_constants.FULL_GATE_PHASE,
|
|
216
|
+
)
|
|
217
|
+
assert mock_check_duplicate_across_files.call_count == 1, (
|
|
218
|
+
"the hook-infrastructure full gate must still call the cross-file duplicate-body check"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _write_duplicate_helper_pair_under_a_non_test_named_tempdir() -> tuple[Path, str]:
|
|
223
|
+
"""Write a hook-infra sibling pair under a ``tempfile.mkdtemp()`` root.
|
|
224
|
+
|
|
225
|
+
``tempfile.mkdtemp()`` names its directory ``tmp<random>``, carrying no
|
|
226
|
+
``test_`` segment, unlike pytest's own ``tmp_path`` fixture — whose
|
|
227
|
+
directory embeds the test's name and so reads as a test path to
|
|
228
|
+
``is_test_file``'s full-path substring match, silently exempting every
|
|
229
|
+
check the caller means to exercise.
|
|
230
|
+
"""
|
|
231
|
+
base_directory = Path(tempfile.mkdtemp())
|
|
232
|
+
hook_blocking_directory = base_directory / "packages" / "claude-dev-env" / "hooks" / "blocking"
|
|
233
|
+
hook_blocking_directory.mkdir(parents=True)
|
|
234
|
+
(hook_blocking_directory / "shipping_helper.py").write_text(
|
|
235
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE, encoding="utf-8"
|
|
236
|
+
)
|
|
237
|
+
return base_directory, str(hook_blocking_directory / "shipping_target.py")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_full_gate_still_catches_a_helper_duplicated_across_two_hook_modules() -> None:
|
|
241
|
+
base_directory, target_path = _write_duplicate_helper_pair_under_a_non_test_named_tempdir()
|
|
242
|
+
try:
|
|
243
|
+
full_gate_issues = code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
244
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE,
|
|
245
|
+
target_path,
|
|
246
|
+
phase=validation_phase_constants.FULL_GATE_PHASE,
|
|
247
|
+
)
|
|
248
|
+
edit_lane_issues = code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
249
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE,
|
|
250
|
+
target_path,
|
|
251
|
+
phase=validation_phase_constants.EDIT_LANE_PHASE,
|
|
252
|
+
)
|
|
253
|
+
finally:
|
|
254
|
+
shutil.rmtree(base_directory, ignore_errors=False)
|
|
255
|
+
|
|
256
|
+
assert any("duplicates" in each_issue for each_issue in full_gate_issues), (
|
|
257
|
+
"the full gate must still catch a helper duplicated across two hook "
|
|
258
|
+
f"modules, got: {full_gate_issues!r}"
|
|
259
|
+
)
|
|
260
|
+
assert not any("duplicates" in each_issue for each_issue in edit_lane_issues), (
|
|
261
|
+
"the edit lane must not run the cross-file duplicate-body check against "
|
|
262
|
+
f"hook infrastructure, got: {edit_lane_issues!r}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_gate_running_selects_the_full_gate(tmp_path: Path) -> None:
|
|
267
|
+
repository_root = tmp_path / "repo"
|
|
268
|
+
repository_root.mkdir()
|
|
269
|
+
file_path = _write_scoped_service_file_with_a_committed_baseline(repository_root)
|
|
270
|
+
|
|
271
|
+
loaded_validate_content = enforcer_loading.load_validate_content_for_full_gate()
|
|
272
|
+
assert loaded_validate_content.__name__ == "validate_content_for_full_gate"
|
|
273
|
+
|
|
274
|
+
scoped_violations = gate_running._scoped_violations_for_file(
|
|
275
|
+
validate_content=loaded_validate_content,
|
|
276
|
+
resolved_path=file_path,
|
|
277
|
+
repository_root=repository_root,
|
|
278
|
+
all_added_lines_for_file={2, 3},
|
|
279
|
+
should_read_staged_content=True,
|
|
280
|
+
)
|
|
281
|
+
assert scoped_violations is not None
|
|
282
|
+
blocking_violations, _advisory_violations = scoped_violations
|
|
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"
|
|
@@ -72,7 +72,7 @@ _ADVISOR_LIFECYCLE_TEXT = (_ADVISOR_REFERENCE_DIR / "lifecycle.md").read_text(
|
|
|
72
72
|
_ADVISOR_WARM_UP_TEXT = (_ADVISOR_REFERENCE_DIR / "warm-up.md").read_text(
|
|
73
73
|
encoding="utf-8"
|
|
74
74
|
)
|
|
75
|
-
_ALL_CONSUMING_SKILL_NAMES = ("team-advisor",
|
|
75
|
+
_ALL_CONSUMING_SKILL_NAMES = ("team-advisor",)
|
|
76
76
|
_ALL_CONSUMING_SKILL_PATHS = tuple(
|
|
77
77
|
_PACKAGE_ROOT / ".agents" / "skills" / each_skill_name / "SKILL.md"
|
|
78
78
|
for each_skill_name in _ALL_CONSUMING_SKILL_NAMES
|
|
@@ -734,16 +734,31 @@ def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
|
734
734
|
)
|
|
735
735
|
|
|
736
736
|
|
|
737
|
-
def
|
|
738
|
-
"""
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
737
|
+
def _assert_roster_names_no_run_all_validators_entry() -> None:
|
|
738
|
+
"""Assert no hosted-roster ``script_relative_path`` names run_all_validators."""
|
|
739
|
+
all_roster_script_paths = {
|
|
740
|
+
each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
741
|
+
}
|
|
742
|
+
assert not any(
|
|
743
|
+
"run_all_validators" in each_script_path
|
|
744
|
+
for each_script_path in all_roster_script_paths
|
|
745
|
+
), (
|
|
746
|
+
"ALL_HOSTED_HOOK_ENTRIES must name no run_all_validators entry, got: "
|
|
747
|
+
f"{sorted(all_roster_script_paths)}"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def test_run_all_validators_is_not_hosted_by_pre_tool_use_dispatcher() -> None:
|
|
752
|
+
"""The main PreToolUse dispatcher's hosted roster names no run_all_validators entry.
|
|
753
|
+
|
|
754
|
+
``hooks.json`` registers the ``run_all_validators`` runner as its own
|
|
755
|
+
PreToolUse ``Write|Edit`` command, separate from the main dispatcher. A
|
|
756
|
+
Python Write payload that ``run_all_validators`` would flag (a type error,
|
|
757
|
+
for instance) still produces ALLOW from the main dispatcher, because its
|
|
758
|
+
roster carries no entry for that runner.
|
|
746
759
|
"""
|
|
760
|
+
_assert_roster_names_no_run_all_validators_entry()
|
|
761
|
+
|
|
747
762
|
python_content_with_type_error = (
|
|
748
763
|
"def add_one(value: int) -> int:\n"
|
|
749
764
|
" return value + 1\n\n\n"
|
|
@@ -754,8 +769,8 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
|
|
|
754
769
|
is_deny, _reason = _parse_hook_decision(dispatcher_result)
|
|
755
770
|
assert not is_deny, (
|
|
756
771
|
"PreToolUse dispatcher must allow a Python Write with a type error — "
|
|
757
|
-
"
|
|
758
|
-
"
|
|
772
|
+
"run_all_validators runs as its own separate PreToolUse command, not "
|
|
773
|
+
"through this dispatcher's roster"
|
|
759
774
|
)
|
|
760
775
|
assert dispatcher_result.returncode == 0, (
|
|
761
776
|
f"Dispatcher must exit 0, got {dispatcher_result.returncode}"
|
|
@@ -122,3 +122,72 @@ def test_allows_arithmetic_expansion() -> None:
|
|
|
122
122
|
"tool_input": {"command": "echo $((2+2))"},
|
|
123
123
|
}
|
|
124
124
|
assert _run_hook(payload).stdout == ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_allows_arithmetic_expansion_with_nested_parens() -> None:
|
|
128
|
+
payload = {
|
|
129
|
+
"tool_name": "Bash",
|
|
130
|
+
"tool_input": {"command": "echo $(( (1+2) * 3 ))"},
|
|
131
|
+
}
|
|
132
|
+
assert _run_hook(payload).stdout == ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
|
|
136
|
+
payload = {
|
|
137
|
+
"tool_name": "Bash",
|
|
138
|
+
"tool_input": {"command": "echo $((echo hi))"},
|
|
139
|
+
}
|
|
140
|
+
assert _run_hook(payload).stdout == ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
|
|
144
|
+
payload = {
|
|
145
|
+
"tool_name": "Bash",
|
|
146
|
+
"tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
|
|
147
|
+
}
|
|
148
|
+
assert _run_hook(payload).stdout == ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
152
|
+
payload = {
|
|
153
|
+
"tool_name": "Bash",
|
|
154
|
+
"tool_input": {"command": "echo $((cd /tmp) && pwd)"},
|
|
155
|
+
}
|
|
156
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
157
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
|
|
161
|
+
payload = {
|
|
162
|
+
"tool_name": "Bash",
|
|
163
|
+
"tool_input": {"command": "echo $((1+2) )"},
|
|
164
|
+
}
|
|
165
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
166
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
170
|
+
payload = {
|
|
171
|
+
"tool_name": "Bash",
|
|
172
|
+
"tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
|
|
173
|
+
}
|
|
174
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
175
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
179
|
+
payload = {
|
|
180
|
+
"tool_name": "Bash",
|
|
181
|
+
"tool_input": {"command": "echo $(( `id -u` + 1 ))"},
|
|
182
|
+
}
|
|
183
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
184
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_denies_nested_command_substitution_without_arithmetic() -> None:
|
|
188
|
+
payload = {
|
|
189
|
+
"tool_name": "Bash",
|
|
190
|
+
"tool_input": {"command": "echo $(echo $(whoami))"},
|
|
191
|
+
}
|
|
192
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
193
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
package/hooks/hooks.json
CHANGED
|
@@ -6,6 +6,13 @@ Extracted from code_rules_enforcer.py to satisfy the constants-location rule.
|
|
|
6
6
|
import re
|
|
7
7
|
import tokenize
|
|
8
8
|
|
|
9
|
+
from hooks_constants.validation_phase_constants import ( # noqa: F401 re-exported for code_rules_enforcer.py
|
|
10
|
+
ALL_VALIDATION_PHASES,
|
|
11
|
+
EDIT_LANE_PHASE,
|
|
12
|
+
FULL_GATE_PHASE,
|
|
13
|
+
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
|
|
14
|
+
)
|
|
15
|
+
|
|
9
16
|
ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
10
17
|
tokenize.TokenError,
|
|
11
18
|
IndentationError,
|
|
@@ -41,7 +41,10 @@ PERMISSION_DECISION_KEY = "permissionDecision"
|
|
|
41
41
|
DENY_DECISION = "deny"
|
|
42
42
|
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
43
43
|
|
|
44
|
-
DOLLAR_PAREN_PATTERN = re.compile(r"\$\(
|
|
44
|
+
DOLLAR_PAREN_PATTERN = re.compile(r"\$\(")
|
|
45
|
+
OPEN_PAREN_CHARACTER = "("
|
|
46
|
+
CLOSE_PAREN_CHARACTER = ")"
|
|
47
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN = 2
|
|
45
48
|
EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
|
|
46
49
|
PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
|
|
47
50
|
SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Directive text for the task_tool_prompt SessionStart hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"TASK_TOOL_DIRECTIVE",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
TASK_TOOL_DIRECTIVE = (
|
|
10
|
+
"TASK TRACKING: Track this session's work with the session's task or todo "
|
|
11
|
+
"tracking tool, whichever this host provides. Create tasks at the start "
|
|
12
|
+
"of the session, then update them as work proceeds."
|
|
13
|
+
)
|
|
@@ -17,12 +17,16 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
17
17
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
18
|
|
|
19
19
|
from hooks_constants.code_rules_enforcer_constants import (
|
|
20
|
+
ALL_VALIDATION_PHASES,
|
|
20
21
|
CONFIG_DIRECTORY_SEGMENT,
|
|
21
22
|
CONSTANTS_MODULE_SUFFIX,
|
|
23
|
+
EDIT_LANE_PHASE,
|
|
24
|
+
FULL_GATE_PHASE,
|
|
22
25
|
FUNCTION_WITH_JSDOC_PATTERN,
|
|
23
26
|
JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
|
|
24
27
|
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
25
28
|
SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
|
|
29
|
+
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
|
|
26
30
|
)
|
|
27
31
|
|
|
28
32
|
|
|
@@ -98,3 +102,11 @@ def test_schema_property_key_pattern_matches_only_the_options_key() -> None:
|
|
|
98
102
|
def test_constants_module_suffix_and_config_directory_segment_are_defined() -> None:
|
|
99
103
|
assert CONSTANTS_MODULE_SUFFIX == "_constants.py"
|
|
100
104
|
assert CONFIG_DIRECTORY_SEGMENT == "config"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_validation_phase_constants_are_reexported_and_render() -> None:
|
|
108
|
+
assert ALL_VALIDATION_PHASES == {EDIT_LANE_PHASE, FULL_GATE_PHASE}
|
|
109
|
+
rendered_message = UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE.format(
|
|
110
|
+
phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
|
|
111
|
+
)
|
|
112
|
+
assert "bogus_phase" in rendered_message
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Constants for the validate_content edit-lane and full-gate phase split.
|
|
2
|
+
|
|
3
|
+
The edit lane and the full gate share every check except six that read
|
|
4
|
+
sibling modules from disk. These names identify each phase, name the checks
|
|
5
|
+
the full gate alone runs, name the hook-infrastructure checks the edit lane
|
|
6
|
+
still runs, and template the error for an unrecognized phase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
EDIT_LANE_PHASE: str = "edit_lane"
|
|
10
|
+
FULL_GATE_PHASE: str = "full_gate"
|
|
11
|
+
ALL_VALIDATION_PHASES: frozenset[str] = frozenset({EDIT_LANE_PHASE, FULL_GATE_PHASE})
|
|
12
|
+
|
|
13
|
+
ALL_FULL_GATE_ONLY_CHECK_NAMES: frozenset[str] = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"check_duplicate_function_body_across_files",
|
|
16
|
+
"advise_cross_skill_duplicate_helper",
|
|
17
|
+
"check_public_function_missing_paired_test",
|
|
18
|
+
"check_test_file_omits_module_public_function",
|
|
19
|
+
"check_orphan_css_classes",
|
|
20
|
+
"check_config_duplicate_path_anchor",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"check_same_file_inline_duplicate_body",
|
|
27
|
+
"check_zero_payload_function_alias",
|
|
28
|
+
"check_unanchored_command_dispatch",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE: str = (
|
|
33
|
+
"unrecognized validation phase {phase!r}; expected one of {all_phases}"
|
|
34
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook — direct the session to track work with its task tool.
|
|
3
|
+
|
|
4
|
+
At session start this hook emits an ``additionalContext`` directive asking
|
|
5
|
+
the session to track its work with the task or todo tracking tool its host
|
|
6
|
+
provides: create tasks at the start of the session, then update them as
|
|
7
|
+
work proceeds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import _path_setup # noqa: F401
|
|
16
|
+
|
|
17
|
+
from hooks_constants.task_tool_prompt_constants import TASK_TOOL_DIRECTIVE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_session_directive() -> str:
|
|
21
|
+
"""Return the task-tool directive emitted at session start."""
|
|
22
|
+
return TASK_TOOL_DIRECTIVE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Emit the task-tool directive as SessionStart additionalContext."""
|
|
27
|
+
payload = {
|
|
28
|
+
"hookSpecificOutput": {
|
|
29
|
+
"hookEventName": "SessionStart",
|
|
30
|
+
"additionalContext": build_session_directive(),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|