claude-dev-env 2.29.0 → 2.30.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 +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/_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 +433 -313
- 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 +281 -0
- package/hooks/blocking/test_fable_spawn_gate.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +21 -0
- package/hooks/hooks_constants/validation_phase_constants.py +34 -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
|
@@ -7,6 +7,8 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
10
12
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
11
13
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
12
14
|
if _BLOCKING_DIRECTORY not in sys.path:
|
|
@@ -16,7 +18,7 @@ if _HOOKS_DIRECTORY not in sys.path:
|
|
|
16
18
|
|
|
17
19
|
from code_rules_enforcer import ( # noqa: E402
|
|
18
20
|
main,
|
|
19
|
-
validate_content,
|
|
21
|
+
validate_content_for_full_gate as validate_content,
|
|
20
22
|
)
|
|
21
23
|
from code_rules_enforcer_test_support import ( # noqa: E402
|
|
22
24
|
run_enforcer_cli,
|
|
@@ -115,7 +117,7 @@ def test_precheck_stream_parameter_carries_no_banned_noun() -> None:
|
|
|
115
117
|
|
|
116
118
|
|
|
117
119
|
def test_precheck_reports_violations_and_exits_nonzero_for_production_candidate(
|
|
118
|
-
tmp_path_factory:
|
|
120
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
119
121
|
) -> None:
|
|
120
122
|
"""A violating candidate judged at a production target prints each violation to
|
|
121
123
|
stdout and exits 1."""
|
|
@@ -138,7 +140,7 @@ def test_precheck_reports_violations_and_exits_nonzero_for_production_candidate(
|
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
def test_precheck_emits_no_output_and_exits_zero_for_clean_candidate(
|
|
141
|
-
tmp_path_factory:
|
|
143
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
142
144
|
) -> None:
|
|
143
145
|
"""A clean candidate produces no stdout and exits 0."""
|
|
144
146
|
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
@@ -155,7 +157,7 @@ def test_precheck_emits_no_output_and_exits_zero_for_clean_candidate(
|
|
|
155
157
|
|
|
156
158
|
|
|
157
159
|
def test_precheck_target_path_drives_test_file_exemptions(
|
|
158
|
-
tmp_path_factory:
|
|
160
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
159
161
|
) -> None:
|
|
160
162
|
"""Production-shaped content judged against a ``test_*.py`` target passes
|
|
161
163
|
because test-file exemptions apply through the target path."""
|
|
@@ -172,7 +174,7 @@ def test_precheck_target_path_drives_test_file_exemptions(
|
|
|
172
174
|
|
|
173
175
|
|
|
174
176
|
def test_precheck_noncode_target_exits_zero_with_no_output(
|
|
175
|
-
tmp_path_factory:
|
|
177
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
176
178
|
) -> None:
|
|
177
179
|
"""A non-code target extension is exempt: the pre-check exits 0 with no output."""
|
|
178
180
|
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
@@ -186,7 +188,7 @@ def test_precheck_noncode_target_exits_zero_with_no_output(
|
|
|
186
188
|
|
|
187
189
|
|
|
188
190
|
def test_precheck_missing_candidate_errors_on_stderr_without_traceback(
|
|
189
|
-
tmp_path_factory:
|
|
191
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
190
192
|
) -> None:
|
|
191
193
|
"""A nonexistent candidate path prints a one-line stderr error and exits
|
|
192
194
|
nonzero without a Python traceback."""
|
|
@@ -204,7 +206,7 @@ def test_precheck_missing_candidate_errors_on_stderr_without_traceback(
|
|
|
204
206
|
|
|
205
207
|
|
|
206
208
|
def test_edit_deny_reason_includes_forecast_and_precheck_hint(
|
|
207
|
-
tmp_path_factory:
|
|
209
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
208
210
|
) -> None:
|
|
209
211
|
"""An Edit whose new_string introduces a violation on a file that already
|
|
210
212
|
contains a separate violation elsewhere blocks on the fragment violation and
|
|
@@ -236,7 +238,7 @@ def test_edit_deny_reason_includes_forecast_and_precheck_hint(
|
|
|
236
238
|
|
|
237
239
|
|
|
238
240
|
def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
|
|
239
|
-
tmp_path_factory:
|
|
241
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
240
242
|
) -> None:
|
|
241
243
|
"""A clean Edit fragment on a file that is dirty elsewhere must not block —
|
|
242
244
|
the forecast never converts a clean-fragment edit into a deny."""
|
|
@@ -258,7 +260,7 @@ def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
|
|
|
258
260
|
|
|
259
261
|
|
|
260
262
|
def test_every_deny_reason_carries_the_precheck_hint(
|
|
261
|
-
tmp_path_factory:
|
|
263
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
262
264
|
) -> None:
|
|
263
265
|
"""A deny with no forecast still appends the pre-check hint to the reason."""
|
|
264
266
|
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
@@ -287,7 +289,7 @@ def test_every_deny_reason_carries_the_precheck_hint(
|
|
|
287
289
|
|
|
288
290
|
|
|
289
291
|
def test_forecast_skipped_when_edit_prior_is_unreconstructable(
|
|
290
|
-
tmp_path_factory:
|
|
292
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
291
293
|
) -> None:
|
|
292
294
|
"""An Edit whose old_string is absent from the file has no reliable prior to
|
|
293
295
|
diff against, so the full-file forecast must not run: a pre-existing inline
|
|
@@ -313,7 +315,7 @@ def test_forecast_skipped_when_edit_prior_is_unreconstructable(
|
|
|
313
315
|
|
|
314
316
|
|
|
315
317
|
def test_forecast_omits_the_fragment_introduced_violation(
|
|
316
|
-
tmp_path_factory:
|
|
318
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
317
319
|
) -> None:
|
|
318
320
|
"""An Edit that introduces the file's only print() blocks on it without the
|
|
319
321
|
forecast re-listing that same violation under its full-file line number."""
|
|
@@ -353,7 +355,7 @@ def test_precheck_missing_candidate_value_exits_two_with_usage() -> None:
|
|
|
353
355
|
|
|
354
356
|
|
|
355
357
|
def test_precheck_flag_shaped_candidate_value_exits_two_with_usage(
|
|
356
|
-
tmp_path_factory:
|
|
358
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
357
359
|
) -> None:
|
|
358
360
|
"""``--check`` immediately followed by ``--as`` is a usage error rather than
|
|
359
361
|
an attempt to read a file literally named ``--as``."""
|
|
@@ -370,7 +372,7 @@ def test_precheck_flag_shaped_candidate_value_exits_two_with_usage(
|
|
|
370
372
|
|
|
371
373
|
|
|
372
374
|
def test_precheck_rejects_unrecognized_trailing_token_with_usage(
|
|
373
|
-
tmp_path_factory:
|
|
375
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
374
376
|
) -> None:
|
|
375
377
|
"""A pre-check vector carrying a token beyond the supported
|
|
376
378
|
``--check <candidate> [--as <target>]`` shape is a usage error: an extra
|
|
@@ -393,7 +395,7 @@ def test_precheck_rejects_unrecognized_trailing_token_with_usage(
|
|
|
393
395
|
|
|
394
396
|
|
|
395
397
|
def test_precheck_strips_candidate_byte_order_mark_before_validation(
|
|
396
|
-
tmp_path_factory:
|
|
398
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
397
399
|
) -> None:
|
|
398
400
|
"""A UTF-8 byte-order mark on the candidate must not hide AST-based
|
|
399
401
|
violations: the candidate is judged exactly as its decoded content would
|
|
@@ -415,7 +417,7 @@ def test_precheck_strips_candidate_byte_order_mark_before_validation(
|
|
|
415
417
|
|
|
416
418
|
|
|
417
419
|
def test_precheck_strips_every_leading_byte_order_mark_before_validation(
|
|
418
|
-
tmp_path_factory:
|
|
420
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
419
421
|
) -> None:
|
|
420
422
|
"""Stacked byte-order marks must not hide AST-based violations: every
|
|
421
423
|
leading mark is stripped, so a double-BOM candidate fails exactly like its
|
|
@@ -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:
|
|
@@ -17,7 +19,7 @@ from code_rules_annotations_length import ( # noqa: E402
|
|
|
17
19
|
FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
18
20
|
)
|
|
19
21
|
from code_rules_enforcer import ( # noqa: E402
|
|
20
|
-
validate_content,
|
|
22
|
+
validate_content_for_full_gate as validate_content,
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
code_rules_enforcer = SimpleNamespace(
|
|
@@ -38,7 +40,7 @@ def _oversized_function_source(name: str) -> str:
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def test_should_emit_advisory_for_duplicated_format_patterns_via_validate_content(
|
|
41
|
-
capsys:
|
|
43
|
+
capsys: pytest.CaptureFixture[str],
|
|
42
44
|
) -> None:
|
|
43
45
|
repeated_pattern_source = (
|
|
44
46
|
"def get_user(user_id: str) -> str:\n"
|
|
@@ -22,7 +22,7 @@ from code_rules_annotations_length import ( # noqa: E402
|
|
|
22
22
|
from code_rules_enforcer import ( # noqa: E402
|
|
23
23
|
main,
|
|
24
24
|
prior_and_post_edit_content,
|
|
25
|
-
validate_content,
|
|
25
|
+
validate_content_for_full_gate as validate_content,
|
|
26
26
|
)
|
|
27
27
|
from code_rules_enforcer_test_support import ( # noqa: E402
|
|
28
28
|
run_serialized_payload_entrypoint,
|
|
@@ -25,7 +25,7 @@ if _PR_LOOP_SCRIPTS_DIRECTORY not in sys.path:
|
|
|
25
25
|
from code_rules_enforcer import ( # noqa: E402
|
|
26
26
|
_fragment_or_deferred_check,
|
|
27
27
|
main,
|
|
28
|
-
validate_content,
|
|
28
|
+
validate_content_for_full_gate as validate_content,
|
|
29
29
|
)
|
|
30
30
|
from code_rules_gate_parts import gate_running # noqa: E402
|
|
31
31
|
from code_rules_gate_parts.tests._repo_test_helpers import ( # noqa: E402
|
|
@@ -248,7 +248,7 @@ def test_should_grandfather_unchanged_string_magic_in_wide_edit() -> None:
|
|
|
248
248
|
" # wide edit that leaves the magic string in place\n"
|
|
249
249
|
" return os.environ['STRIPE_SECRET']\n"
|
|
250
250
|
)
|
|
251
|
-
issues = code_rules_enforcer.
|
|
251
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
252
252
|
proposed_fragment,
|
|
253
253
|
PRODUCTION_FILE_PATH,
|
|
254
254
|
prior_fragment,
|
|
@@ -272,7 +272,7 @@ def test_should_block_new_string_magic_in_wide_edit() -> None:
|
|
|
272
272
|
"def fetch_secret() -> str:\n"
|
|
273
273
|
" return os.environ['STRIPE_SECRET']\n"
|
|
274
274
|
)
|
|
275
|
-
issues = code_rules_enforcer.
|
|
275
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
276
276
|
proposed_fragment,
|
|
277
277
|
PRODUCTION_FILE_PATH,
|
|
278
278
|
prior_fragment,
|
|
@@ -300,7 +300,7 @@ def test_should_block_second_identical_string_magic_per_occurrence() -> None:
|
|
|
300
300
|
"def fetch_backup() -> str:\n"
|
|
301
301
|
" return os.environ['STRIPE_SECRET']\n"
|
|
302
302
|
)
|
|
303
|
-
issues = code_rules_enforcer.
|
|
303
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
304
304
|
proposed_fragment,
|
|
305
305
|
PRODUCTION_FILE_PATH,
|
|
306
306
|
prior_fragment,
|
|
@@ -320,7 +320,7 @@ def test_should_surface_magic_when_gate_defers_scope_with_head_as_old() -> None:
|
|
|
320
320
|
" old_number = 9999\n"
|
|
321
321
|
" return old_number\n"
|
|
322
322
|
)
|
|
323
|
-
issues = code_rules_enforcer.
|
|
323
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
324
324
|
source,
|
|
325
325
|
PRODUCTION_FILE_PATH,
|
|
326
326
|
source,
|
|
@@ -149,7 +149,7 @@ def test_should_flag_thin_wrapper_with_module_docstring() -> None:
|
|
|
149
149
|
)
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def
|
|
152
|
+
def test_validate_content_for_full_gate_uses_empty_full_file_content_over_pre_edit_fragment() -> None:
|
|
153
153
|
"""An empty-string `full_file_content` must be honored, not silently replaced with `content`.
|
|
154
154
|
|
|
155
155
|
Regression for loop1-8: the `or` short-circuit at line 3775 collapsed
|
|
@@ -159,7 +159,7 @@ def test_validate_content_uses_empty_full_file_content_over_pre_edit_fragment()
|
|
|
159
159
|
not a thin wrapper, but a pre-edit fragment with imports + __all__ is.
|
|
160
160
|
"""
|
|
161
161
|
pre_edit_fragment = "from real_module import do_thing\n__all__ = ['do_thing']\n"
|
|
162
|
-
issues = code_rules_enforcer.
|
|
162
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
163
163
|
pre_edit_fragment,
|
|
164
164
|
PRODUCTION_FILE_PATH,
|
|
165
165
|
full_file_content="",
|
|
@@ -240,7 +240,7 @@ def test_should_not_flag_recursive_self_call() -> None:
|
|
|
240
240
|
)
|
|
241
241
|
|
|
242
242
|
|
|
243
|
-
def
|
|
243
|
+
def test_should_flag_through_validate_content_for_full_gate_for_hook_file() -> None:
|
|
244
244
|
source = (
|
|
245
245
|
"def find_bare_path_segments(content: str) -> set[str]:\n"
|
|
246
246
|
" return {part for part in content.split() if part}\n"
|
|
@@ -248,7 +248,7 @@ def test_should_flag_through_validate_content_for_hook_file() -> None:
|
|
|
248
248
|
"def find_bare_index_segments(content: str) -> set[str]:\n"
|
|
249
249
|
" return find_bare_path_segments(content)\n"
|
|
250
250
|
)
|
|
251
|
-
issues = code_rules_enforcer.
|
|
251
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(source, HOOK_INFRASTRUCTURE_PATH)
|
|
252
252
|
assert any("find_bare_index_segments" in each for each in issues), (
|
|
253
253
|
f"validate_content must surface the alias for hook files, got: {issues!r}"
|
|
254
254
|
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A pass-through alias inside a hook module is the motivating case for the
|
|
4
4
|
zero-payload-alias check, so the deny must fire on the same PreToolUse path a
|
|
5
5
|
live Write into ``packages/claude-dev-env/hooks/blocking`` would take — not only
|
|
6
|
-
through ``
|
|
6
|
+
through ``validate_content_for_edit_lane``, which hook files never reach at PreToolUse. These
|
|
7
7
|
tests drive the real ``main()`` stdin entry point and the pre-check CLI with a
|
|
8
8
|
hook-infrastructure target.
|
|
9
9
|
|
|
@@ -66,7 +66,7 @@ def test_write_of_pass_through_alias_into_hook_directory_denies(
|
|
|
66
66
|
The target lives under a hook-infrastructure path the full code-rules suite
|
|
67
67
|
exempts, so this proves the zero-payload-alias check still fires on the exact
|
|
68
68
|
directory its docstring names as the motivating case — at the PreToolUse Write
|
|
69
|
-
point, not only through ``
|
|
69
|
+
point, not only through ``validate_content_for_edit_lane``."""
|
|
70
70
|
new_file = hook_blocking_dir / "new_blocker.py"
|
|
71
71
|
captured_stdout, _exit_code = run_write_entrypoint(
|
|
72
72
|
main,
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Tests for the validate_content edit-lane and full-gate phase split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import importlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import inspect
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
from unittest.mock import DEFAULT, MagicMock, patch
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
|
|
21
|
+
"""Load and execute the module found at module_path, under module_name."""
|
|
22
|
+
specification = importlib.util.spec_from_file_location(module_name, module_path)
|
|
23
|
+
assert specification is not None
|
|
24
|
+
assert specification.loader is not None
|
|
25
|
+
module = importlib.util.module_from_spec(specification)
|
|
26
|
+
specification.loader.exec_module(module)
|
|
27
|
+
return module
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
31
|
+
_HOOKS_DIRECTORY = _BLOCKING_DIRECTORY.parent
|
|
32
|
+
_PACKAGE_ROOT = _HOOKS_DIRECTORY.parent
|
|
33
|
+
_PR_LOOP_SCRIPTS_DIRECTORY = str(_PACKAGE_ROOT / "_shared" / "pr-loop" / "scripts")
|
|
34
|
+
|
|
35
|
+
code_rules_enforcer = _load_module_from_path(
|
|
36
|
+
"code_rules_enforcer", _BLOCKING_DIRECTORY / "code_rules_enforcer.py"
|
|
37
|
+
)
|
|
38
|
+
validation_phase_constants = _load_module_from_path(
|
|
39
|
+
"validation_phase_constants",
|
|
40
|
+
_HOOKS_DIRECTORY / "hooks_constants" / "validation_phase_constants.py",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if _PR_LOOP_SCRIPTS_DIRECTORY not in sys.path:
|
|
44
|
+
sys.path.insert(0, _PR_LOOP_SCRIPTS_DIRECTORY)
|
|
45
|
+
enforcer_loading = importlib.import_module("code_rules_gate_parts.enforcer_loading")
|
|
46
|
+
gate_running = importlib.import_module("code_rules_gate_parts.gate_running")
|
|
47
|
+
repo_test_helpers = importlib.import_module("code_rules_gate_parts.tests._repo_test_helpers")
|
|
48
|
+
|
|
49
|
+
PRODUCTION_FILE_PATH = "packages/app/services.py"
|
|
50
|
+
SAMPLE_PYTHON_CONTENT = "def add_one(value: int) -> int:\n return value + 1\n"
|
|
51
|
+
UNKNOWN_PHASE_NAME = "not_a_real_phase"
|
|
52
|
+
HOOK_INFRASTRUCTURE_FILE_PATH = "/repo/packages/claude-dev-env/hooks/blocking/example.py"
|
|
53
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE = (
|
|
54
|
+
"def compute_shipping_total(order_total: float, tax_rate: float) -> float:\n"
|
|
55
|
+
" taxed_total = order_total * (1 + tax_rate)\n"
|
|
56
|
+
" rounded_total = round(taxed_total, 2)\n"
|
|
57
|
+
" return rounded_total\n"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_all_validation_phases_holds_exactly_the_two_phases() -> None:
|
|
62
|
+
assert validation_phase_constants.ALL_VALIDATION_PHASES == {
|
|
63
|
+
validation_phase_constants.EDIT_LANE_PHASE,
|
|
64
|
+
validation_phase_constants.FULL_GATE_PHASE,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_unknown_phase_raises_value_error_naming_the_rejected_value() -> None:
|
|
69
|
+
with pytest.raises(ValueError, match=UNKNOWN_PHASE_NAME):
|
|
70
|
+
code_rules_enforcer.validate_content_for_phase(
|
|
71
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH, phase=UNKNOWN_PHASE_NAME
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_validate_content_for_phase_requires_phase_keyword() -> None:
|
|
76
|
+
with pytest.raises(TypeError):
|
|
77
|
+
code_rules_enforcer.validate_content_for_phase(SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _full_gate_only_default_kwargs() -> dict[str, object]:
|
|
81
|
+
return {
|
|
82
|
+
each_name: DEFAULT
|
|
83
|
+
for each_name in validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _patched_full_gate_only_checks() -> contextlib.AbstractContextManager[dict[str, MagicMock]]:
|
|
88
|
+
"""Return the patch.multiple context manager for the six full-gate-only checks.
|
|
89
|
+
|
|
90
|
+
::
|
|
91
|
+
|
|
92
|
+
mypy's patch.multiple stub only accepts an ``Any | str`` target, so a
|
|
93
|
+
dynamically loaded ``ModuleType`` target needs a local type: ignore.
|
|
94
|
+
"""
|
|
95
|
+
return patch.multiple( # type: ignore[call-overload] # mypy stub rejects ModuleType targets
|
|
96
|
+
code_rules_enforcer, **_full_gate_only_default_kwargs()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _configure_full_gate_only_mock_returns(all_mocks: dict[str, MagicMock]) -> None:
|
|
101
|
+
for each_name, each_mock in all_mocks.items():
|
|
102
|
+
each_mock.return_value = None if each_name == "advise_cross_skill_duplicate_helper" else []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_edit_lane_calls_none_of_the_six_full_gate_only_checks() -> None:
|
|
106
|
+
with _patched_full_gate_only_checks() as all_mocks:
|
|
107
|
+
_configure_full_gate_only_mock_returns(all_mocks)
|
|
108
|
+
code_rules_enforcer.validate_content_for_edit_lane(
|
|
109
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
110
|
+
)
|
|
111
|
+
for each_name, each_mock in all_mocks.items():
|
|
112
|
+
assert each_mock.call_count == 0, f"edit lane must not call {each_name}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_full_gate_calls_all_six_full_gate_only_checks() -> None:
|
|
116
|
+
with _patched_full_gate_only_checks() as all_mocks:
|
|
117
|
+
_configure_full_gate_only_mock_returns(all_mocks)
|
|
118
|
+
code_rules_enforcer.validate_content_for_full_gate(
|
|
119
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
120
|
+
)
|
|
121
|
+
for each_name, each_mock in all_mocks.items():
|
|
122
|
+
assert each_mock.call_count >= 1, f"full gate must call {each_name}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_both_phases_report_identical_findings_with_the_six_spied_out() -> None:
|
|
126
|
+
with _patched_full_gate_only_checks() as all_mocks:
|
|
127
|
+
_configure_full_gate_only_mock_returns(all_mocks)
|
|
128
|
+
edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
|
|
129
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
130
|
+
)
|
|
131
|
+
full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
132
|
+
SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
|
|
133
|
+
)
|
|
134
|
+
assert edit_lane_issues == full_gate_issues
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_no_caller_should_reach_a_phase_less_validator() -> None:
|
|
138
|
+
assert not hasattr(code_rules_enforcer, "validate_content"), (
|
|
139
|
+
"validate_content must not survive as a callable on the enforcer module"
|
|
140
|
+
)
|
|
141
|
+
enforcer_source = inspect.getsource(code_rules_enforcer)
|
|
142
|
+
assert re.search(r"\bvalidate_content\(", enforcer_source) is None, (
|
|
143
|
+
"no call site in code_rules_enforcer.py may reach a phase-less validator"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_precheck_selects_the_full_gate() -> None:
|
|
148
|
+
precheck_source = inspect.getsource(code_rules_enforcer._run_precheck)
|
|
149
|
+
assert "validate_content_for_full_gate(" in precheck_source, (
|
|
150
|
+
"the --check precheck must run the full-gate verdict"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_codex_patch_issues_selects_the_edit_lane() -> None:
|
|
155
|
+
"""Pin the apply_patch mutation path to the edit-lane verdict."""
|
|
156
|
+
codex_patch_issues_source = inspect.getsource(code_rules_enforcer._codex_patch_issues)
|
|
157
|
+
assert "validate_content_for_edit_lane(" in codex_patch_issues_source, (
|
|
158
|
+
"the apply_patch branch must run the edit-lane verdict"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_forecast_full_file_violations_selects_the_edit_lane() -> None:
|
|
163
|
+
"""Pin the full-file forecast pass to the edit-lane verdict."""
|
|
164
|
+
forecast_source = inspect.getsource(code_rules_enforcer._forecast_full_file_violations)
|
|
165
|
+
assert "validate_content_for_edit_lane(" in forecast_source, (
|
|
166
|
+
"the full-file forecast pass must run the edit-lane verdict"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_report_blocking_violations_selects_the_edit_lane() -> None:
|
|
171
|
+
"""Pin the PreToolUse Write and Edit main path to the edit-lane verdict."""
|
|
172
|
+
report_blocking_violations_source = inspect.getsource(
|
|
173
|
+
code_rules_enforcer._report_blocking_violations
|
|
174
|
+
)
|
|
175
|
+
assert "validate_content_for_edit_lane(" in report_blocking_violations_source, (
|
|
176
|
+
"the PreToolUse Write and Edit main path must run the edit-lane verdict"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_scoped_service_file_with_a_committed_baseline(repository_root: Path) -> Path:
|
|
181
|
+
repo_test_helpers.init_repository(repository_root)
|
|
182
|
+
return repo_test_helpers.write_commit_and_stage_change(
|
|
183
|
+
repository_root,
|
|
184
|
+
"service.py",
|
|
185
|
+
"def read_port() -> int:\n return 0\n",
|
|
186
|
+
"def read_port() -> int:\n port_number = 9999\n return port_number\n",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_hook_infrastructure_edit_lane_skips_the_cross_file_duplicate_check() -> None:
|
|
191
|
+
with patch.object(
|
|
192
|
+
code_rules_enforcer, "check_duplicate_function_body_across_files"
|
|
193
|
+
) as mock_check_duplicate_across_files:
|
|
194
|
+
mock_check_duplicate_across_files.return_value = []
|
|
195
|
+
code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
196
|
+
SAMPLE_PYTHON_CONTENT,
|
|
197
|
+
HOOK_INFRASTRUCTURE_FILE_PATH,
|
|
198
|
+
phase=validation_phase_constants.EDIT_LANE_PHASE,
|
|
199
|
+
)
|
|
200
|
+
assert mock_check_duplicate_across_files.call_count == 0, (
|
|
201
|
+
"the hook-infrastructure edit lane must not call the cross-file duplicate-body check"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_hook_infrastructure_full_gate_calls_the_cross_file_duplicate_check() -> None:
|
|
206
|
+
with patch.object(
|
|
207
|
+
code_rules_enforcer, "check_duplicate_function_body_across_files"
|
|
208
|
+
) as mock_check_duplicate_across_files:
|
|
209
|
+
mock_check_duplicate_across_files.return_value = []
|
|
210
|
+
code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
211
|
+
SAMPLE_PYTHON_CONTENT,
|
|
212
|
+
HOOK_INFRASTRUCTURE_FILE_PATH,
|
|
213
|
+
phase=validation_phase_constants.FULL_GATE_PHASE,
|
|
214
|
+
)
|
|
215
|
+
assert mock_check_duplicate_across_files.call_count == 1, (
|
|
216
|
+
"the hook-infrastructure full gate must still call the cross-file duplicate-body check"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _write_duplicate_helper_pair_under_a_non_test_named_tempdir() -> tuple[Path, str]:
|
|
221
|
+
"""Write a hook-infra sibling pair under a ``tempfile.mkdtemp()`` root.
|
|
222
|
+
|
|
223
|
+
``tempfile.mkdtemp()`` names its directory ``tmp<random>``, carrying no
|
|
224
|
+
``test_`` segment, unlike pytest's own ``tmp_path`` fixture — whose
|
|
225
|
+
directory embeds the test's name and so reads as a test path to
|
|
226
|
+
``is_test_file``'s full-path substring match, silently exempting every
|
|
227
|
+
check the caller means to exercise.
|
|
228
|
+
"""
|
|
229
|
+
base_directory = Path(tempfile.mkdtemp())
|
|
230
|
+
hook_blocking_directory = base_directory / "packages" / "claude-dev-env" / "hooks" / "blocking"
|
|
231
|
+
hook_blocking_directory.mkdir(parents=True)
|
|
232
|
+
(hook_blocking_directory / "shipping_helper.py").write_text(
|
|
233
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE, encoding="utf-8"
|
|
234
|
+
)
|
|
235
|
+
return base_directory, str(hook_blocking_directory / "shipping_target.py")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_full_gate_still_catches_a_helper_duplicated_across_two_hook_modules() -> None:
|
|
239
|
+
base_directory, target_path = _write_duplicate_helper_pair_under_a_non_test_named_tempdir()
|
|
240
|
+
try:
|
|
241
|
+
full_gate_issues = code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
242
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE,
|
|
243
|
+
target_path,
|
|
244
|
+
phase=validation_phase_constants.FULL_GATE_PHASE,
|
|
245
|
+
)
|
|
246
|
+
edit_lane_issues = code_rules_enforcer._hook_infrastructure_blocking_issues(
|
|
247
|
+
_HOOK_MODULE_DUPLICATE_BODY_SOURCE,
|
|
248
|
+
target_path,
|
|
249
|
+
phase=validation_phase_constants.EDIT_LANE_PHASE,
|
|
250
|
+
)
|
|
251
|
+
finally:
|
|
252
|
+
shutil.rmtree(base_directory, ignore_errors=False)
|
|
253
|
+
|
|
254
|
+
assert any("duplicates" in each_issue for each_issue in full_gate_issues), (
|
|
255
|
+
"the full gate must still catch a helper duplicated across two hook "
|
|
256
|
+
f"modules, got: {full_gate_issues!r}"
|
|
257
|
+
)
|
|
258
|
+
assert not any("duplicates" in each_issue for each_issue in edit_lane_issues), (
|
|
259
|
+
"the edit lane must not run the cross-file duplicate-body check against "
|
|
260
|
+
f"hook infrastructure, got: {edit_lane_issues!r}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_gate_running_selects_the_full_gate(tmp_path: Path) -> None:
|
|
265
|
+
repository_root = tmp_path / "repo"
|
|
266
|
+
repository_root.mkdir()
|
|
267
|
+
file_path = _write_scoped_service_file_with_a_committed_baseline(repository_root)
|
|
268
|
+
|
|
269
|
+
loaded_validate_content = enforcer_loading.load_validate_content_for_full_gate()
|
|
270
|
+
assert loaded_validate_content.__name__ == "validate_content_for_full_gate"
|
|
271
|
+
|
|
272
|
+
scoped_violations = gate_running._scoped_violations_for_file(
|
|
273
|
+
validate_content=loaded_validate_content,
|
|
274
|
+
resolved_path=file_path,
|
|
275
|
+
repository_root=repository_root,
|
|
276
|
+
all_added_lines_for_file={2, 3},
|
|
277
|
+
should_read_staged_content=True,
|
|
278
|
+
)
|
|
279
|
+
assert scoped_violations is not None
|
|
280
|
+
blocking_violations, _advisory_violations = scoped_violations
|
|
281
|
+
assert any("9999" in each_issue for each_issue in blocking_violations)
|
|
@@ -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}"
|
|
@@ -6,6 +6,14 @@ 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_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
|
|
11
|
+
ALL_VALIDATION_PHASES,
|
|
12
|
+
EDIT_LANE_PHASE,
|
|
13
|
+
FULL_GATE_PHASE,
|
|
14
|
+
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
|
|
15
|
+
)
|
|
16
|
+
|
|
9
17
|
ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
10
18
|
tokenize.TokenError,
|
|
11
19
|
IndentationError,
|