claude-dev-env 2.31.0 → 2.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/CODE_RULES.md +2 -1
- package/hooks/blocking/code_rules_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +32 -14
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/pr_description_writer_gate.py +18 -25
- package/hooks/blocking/tdd_enforcer.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
- package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -2
- package/hooks/blocking/test_state_description_blocker.py +99 -53
- package/hooks/blocking/test_tdd_enforcer.py +64 -0
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
- package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
- package/hooks/validators/abbreviation_checks.py +3 -3
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +23 -80
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +3 -2
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +190 -145
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +16 -16
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
|
@@ -45,7 +45,7 @@ def test_run_fast_save_validators_covers_every_non_mypy_non_ruff_check(
|
|
|
45
45
|
|
|
46
46
|
all_outcomes = run_fast_save_validators([target_file])
|
|
47
47
|
|
|
48
|
-
assert {each_outcome.
|
|
48
|
+
assert {each_outcome.name for each_outcome in all_outcomes} == (
|
|
49
49
|
_ALL_EXPECTED_FAST_SAVE_VALIDATOR_NAMES
|
|
50
50
|
)
|
|
51
51
|
|
|
@@ -56,8 +56,8 @@ def test_run_fast_save_validators_excludes_mypy_and_ruff(tmp_path: Path) -> None
|
|
|
56
56
|
|
|
57
57
|
all_outcomes = run_fast_save_validators([target_file])
|
|
58
58
|
|
|
59
|
-
assert all(each_outcome.
|
|
60
|
-
assert all(each_outcome.
|
|
59
|
+
assert all(each_outcome.name != "Mypy" for each_outcome in all_outcomes)
|
|
60
|
+
assert all(each_outcome.name != "Ruff" for each_outcome in all_outcomes)
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def test_run_fast_save_validators_spawns_no_subprocess(
|
|
@@ -98,9 +98,9 @@ def test_magic_values_outcome_matches_direct_validate_file_call(
|
|
|
98
98
|
all_outcomes = run_fast_save_validators([target_file])
|
|
99
99
|
|
|
100
100
|
magic_values_outcome = next(
|
|
101
|
-
each for each in all_outcomes if each.
|
|
101
|
+
each for each in all_outcomes if each.name == "Magic Values"
|
|
102
102
|
)
|
|
103
|
-
assert magic_values_outcome.
|
|
103
|
+
assert magic_values_outcome.output == "\n".join(expected_lines)
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
|
|
@@ -111,8 +111,8 @@ def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
|
|
|
111
111
|
|
|
112
112
|
all_outcomes = run_fast_save_validators([target_file])
|
|
113
113
|
|
|
114
|
-
todo_outcome = next(each for each in all_outcomes if each.
|
|
115
|
-
assert todo_outcome.
|
|
114
|
+
todo_outcome = next(each for each in all_outcomes if each.name == "TODO Tracking")
|
|
115
|
+
assert todo_outcome.output == "\n".join(expected_lines)
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) -> None:
|
|
@@ -125,9 +125,9 @@ def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) ->
|
|
|
125
125
|
all_outcomes = run_fast_save_validators([target_file])
|
|
126
126
|
|
|
127
127
|
abbreviations_outcome = next(
|
|
128
|
-
each for each in all_outcomes if each.
|
|
128
|
+
each for each in all_outcomes if each.name == "Abbreviations"
|
|
129
129
|
)
|
|
130
|
-
assert abbreviations_outcome.
|
|
130
|
+
assert abbreviations_outcome.passed is True
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> None:
|
|
@@ -139,8 +139,8 @@ def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> Non
|
|
|
139
139
|
|
|
140
140
|
all_outcomes = run_fast_save_validators([target_file])
|
|
141
141
|
|
|
142
|
-
react_outcome = next(each for each in all_outcomes if each.
|
|
143
|
-
assert react_outcome.
|
|
142
|
+
react_outcome = next(each for each in all_outcomes if each.name == "React")
|
|
143
|
+
assert react_outcome.output == "\n".join(expected_lines)
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
def test_test_safety_outcome_matches_direct_check_function_calls(
|
|
@@ -155,8 +155,8 @@ def test_test_safety_outcome_matches_direct_check_function_calls(
|
|
|
155
155
|
|
|
156
156
|
all_outcomes = run_fast_save_validators([target_file])
|
|
157
157
|
|
|
158
|
-
test_safety_outcome = next(each for each in all_outcomes if each.
|
|
159
|
-
assert test_safety_outcome.
|
|
158
|
+
test_safety_outcome = next(each for each in all_outcomes if each.name == "Test Safety")
|
|
159
|
+
assert test_safety_outcome.output == "\n".join(expected_lines)
|
|
160
160
|
|
|
161
161
|
|
|
162
162
|
def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
|
|
@@ -166,7 +166,7 @@ def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
|
|
|
166
166
|
all_outcomes = run_fast_save_validators([target_file])
|
|
167
167
|
|
|
168
168
|
abbreviations_outcome = next(
|
|
169
|
-
each for each in all_outcomes if each.
|
|
169
|
+
each for each in all_outcomes if each.name == "Abbreviations"
|
|
170
170
|
)
|
|
171
|
-
assert abbreviations_outcome.
|
|
172
|
-
assert abbreviations_outcome.
|
|
171
|
+
assert abbreviations_outcome.passed is True
|
|
172
|
+
assert abbreviations_outcome.output == "No Python files to check"
|
|
@@ -106,8 +106,16 @@ def test_ensure_real_repository_target_accepts_a_repo_file_under_the_os_temp_roo
|
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
def test_ensure_real_repository_target_refuses_a_scratch_path_outside_the_repository_root(
|
|
109
|
-
tmp_path: Path
|
|
109
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
110
110
|
) -> None:
|
|
111
|
+
"""The refusal is under test, so this pins the temp root it is judged against.
|
|
112
|
+
|
|
113
|
+
``--basetemp`` places ``tmp_path`` wherever the caller asks, so a run that
|
|
114
|
+
points it outside the OS temp root leaves the scratch target real and this
|
|
115
|
+
assertion measures the caller's flags. Naming the root here keeps the
|
|
116
|
+
subject of the test inside the test.
|
|
117
|
+
"""
|
|
118
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
111
119
|
repository_root = tmp_path / "repo"
|
|
112
120
|
repository_root.mkdir()
|
|
113
121
|
scratch_target = tmp_path / "scratch.py"
|
|
@@ -194,6 +202,7 @@ def test_measure_hosted_command_wall_times_honors_a_repository_root_override(
|
|
|
194
202
|
gap the wrapper-plumb-through check caught.
|
|
195
203
|
"""
|
|
196
204
|
monkeypatch.setattr(hook_timing_harness, "write_edit_hook_commands", lambda path: [])
|
|
205
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
197
206
|
repository_root = tmp_path / "repo"
|
|
198
207
|
real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
|
|
199
208
|
real_file.parent.mkdir(parents=True)
|
|
@@ -19,6 +19,7 @@ from .run_all_validators import (
|
|
|
19
19
|
ValidatorResult,
|
|
20
20
|
_escapes_temporary_root,
|
|
21
21
|
_hooks_subprocess_working_directory_and_environment,
|
|
22
|
+
_scope_agnostic_message,
|
|
22
23
|
_scope_new_and_preexisting,
|
|
23
24
|
_temporary_path_preserving_directory_signal,
|
|
24
25
|
_violation_line_number,
|
|
@@ -33,6 +34,9 @@ CONFIG_DIR_TARGET_PATH = (
|
|
|
33
34
|
)
|
|
34
35
|
PARENT_TRAVERSAL_TARGET_PATH = "../../escape_target.py"
|
|
35
36
|
RELATIVE_CONFIG_TARGET_PATH = "config/x.py"
|
|
37
|
+
PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH = (
|
|
38
|
+
"/srv/projects/demo/test_edit_introducing_new_viol0/legacy_module.py"
|
|
39
|
+
)
|
|
36
40
|
|
|
37
41
|
CLEAN_PYTHON_SOURCE = (
|
|
38
42
|
"def add_two_numbers(first_number: int, second_number: int) -> int:\n"
|
|
@@ -228,6 +232,38 @@ class TestTemporaryPathPreservingDirectorySignal:
|
|
|
228
232
|
)
|
|
229
233
|
assert staged_path == staging_root / "legacy_module.py"
|
|
230
234
|
|
|
235
|
+
def test_pytest_scratch_segment_stages_flat_outside_every_temporary_root(
|
|
236
|
+
self, tmp_path: Path
|
|
237
|
+
) -> None:
|
|
238
|
+
staging_root = tmp_path / "staging_root"
|
|
239
|
+
staging_root.mkdir()
|
|
240
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
241
|
+
staging_root, PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH
|
|
242
|
+
)
|
|
243
|
+
assert staged_path == staging_root / "legacy_module.py"
|
|
244
|
+
|
|
245
|
+
def test_test_helpers_directory_keeps_its_exemption_under_a_temporary_root(
|
|
246
|
+
self, tmp_path: Path
|
|
247
|
+
) -> None:
|
|
248
|
+
helpers_target = tmp_path / "pkg" / "test_helpers" / "worker.py"
|
|
249
|
+
staging_root = tmp_path / "staging_root"
|
|
250
|
+
staging_root.mkdir()
|
|
251
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
252
|
+
staging_root, str(helpers_target)
|
|
253
|
+
)
|
|
254
|
+
assert staged_path == staging_root / "test_helpers" / "worker.py"
|
|
255
|
+
|
|
256
|
+
def test_scripts_directory_keeps_its_exemption_under_a_temporary_root(
|
|
257
|
+
self, tmp_path: Path
|
|
258
|
+
) -> None:
|
|
259
|
+
scripts_target = tmp_path / "pkg" / "scripts" / "run_job.py"
|
|
260
|
+
staging_root = tmp_path / "staging_root"
|
|
261
|
+
staging_root.mkdir()
|
|
262
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
263
|
+
staging_root, str(scripts_target)
|
|
264
|
+
)
|
|
265
|
+
assert staged_path == staging_root / "scripts" / "run_job.py"
|
|
266
|
+
|
|
231
267
|
def test_relative_config_path_preserves_config_under_system_temp_cwd(
|
|
232
268
|
self, monkeypatch: pytest.MonkeyPatch
|
|
233
269
|
) -> None:
|
|
@@ -430,6 +466,100 @@ class TestBaselineScopedGate:
|
|
|
430
466
|
assert '"permissionDecision": "deny"' in completed.stdout
|
|
431
467
|
assert "Magic Values" in completed.stdout
|
|
432
468
|
|
|
469
|
+
def test_renaming_a_function_keeps_its_violation_grandfathered(
|
|
470
|
+
self, tmp_path: Path
|
|
471
|
+
) -> None:
|
|
472
|
+
target_file = tmp_path / "legacy_module.py"
|
|
473
|
+
target_file.write_text(
|
|
474
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
475
|
+
)
|
|
476
|
+
completed = run_gate(
|
|
477
|
+
{
|
|
478
|
+
"tool_name": "Edit",
|
|
479
|
+
"tool_input": {
|
|
480
|
+
"file_path": str(target_file),
|
|
481
|
+
"old_string": "def over_long_function(",
|
|
482
|
+
"new_string": "def accumulate_running_total(",
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
assert completed.returncode == 0, completed.stderr
|
|
487
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
488
|
+
assert "Code Quality" in completed.stderr
|
|
489
|
+
|
|
490
|
+
def test_renaming_a_function_denies_a_second_violation_of_the_same_shape(
|
|
491
|
+
self, tmp_path: Path
|
|
492
|
+
) -> None:
|
|
493
|
+
target_file = tmp_path / "legacy_module.py"
|
|
494
|
+
target_file.write_text(
|
|
495
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
496
|
+
)
|
|
497
|
+
completed = run_gate(
|
|
498
|
+
{
|
|
499
|
+
"tool_name": "Edit",
|
|
500
|
+
"tool_input": {
|
|
501
|
+
"file_path": str(target_file),
|
|
502
|
+
"old_string": "def over_long_function(",
|
|
503
|
+
"new_string": (
|
|
504
|
+
_over_long_function_source("second_over_long_function")
|
|
505
|
+
+ "\n\n"
|
|
506
|
+
+ "def accumulate_running_total("
|
|
507
|
+
),
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
)
|
|
511
|
+
assert completed.returncode == 0, completed.stderr
|
|
512
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
513
|
+
assert "Code Quality" in completed.stdout
|
|
514
|
+
|
|
515
|
+
def test_renaming_a_function_denies_a_new_violation_of_another_validator(
|
|
516
|
+
self, tmp_path: Path
|
|
517
|
+
) -> None:
|
|
518
|
+
target_file = tmp_path / "legacy_module.py"
|
|
519
|
+
target_file.write_text(
|
|
520
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
521
|
+
)
|
|
522
|
+
completed = run_gate(
|
|
523
|
+
{
|
|
524
|
+
"tool_name": "Edit",
|
|
525
|
+
"tool_input": {
|
|
526
|
+
"file_path": str(target_file),
|
|
527
|
+
"old_string": "def over_long_function(",
|
|
528
|
+
"new_string": "import os\n\n\ndef accumulate_running_total(",
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
assert completed.returncode == 0, completed.stderr
|
|
533
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
534
|
+
assert "F401" in completed.stdout
|
|
535
|
+
|
|
536
|
+
def test_moving_a_violation_to_another_function_keeps_it_grandfathered(
|
|
537
|
+
self, tmp_path: Path
|
|
538
|
+
) -> None:
|
|
539
|
+
target_file = tmp_path / "legacy_module.py"
|
|
540
|
+
target_file.write_text(RUFF_DIRTY_FUNCTION_SOURCE, encoding="utf-8")
|
|
541
|
+
completed = run_gate(
|
|
542
|
+
{
|
|
543
|
+
"tool_name": "Edit",
|
|
544
|
+
"tool_input": {
|
|
545
|
+
"file_path": str(target_file),
|
|
546
|
+
"old_string": RUFF_DIRTY_FUNCTION_SOURCE,
|
|
547
|
+
"new_string": (
|
|
548
|
+
"def compute_total() -> int:\n"
|
|
549
|
+
" return 1\n"
|
|
550
|
+
"\n"
|
|
551
|
+
"\n"
|
|
552
|
+
"def compute_subtotal() -> int:\n"
|
|
553
|
+
" unused_intermediate = 1\n"
|
|
554
|
+
" return 1\n"
|
|
555
|
+
),
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
)
|
|
559
|
+
assert completed.returncode == 0, completed.stderr
|
|
560
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
561
|
+
assert "F841" in completed.stderr
|
|
562
|
+
|
|
433
563
|
def test_new_module_scope_ruff_violation_with_dirty_function_denies(
|
|
434
564
|
self, tmp_path: Path
|
|
435
565
|
) -> None:
|
|
@@ -451,6 +581,37 @@ class TestBaselineScopedGate:
|
|
|
451
581
|
assert "F841" not in completed.stdout
|
|
452
582
|
|
|
453
583
|
|
|
584
|
+
class TestScopeAgnosticMessage:
|
|
585
|
+
def test_masks_the_enclosing_function_name_and_its_metrics(self) -> None:
|
|
586
|
+
masked_message = _scope_agnostic_message(
|
|
587
|
+
"over_long_function", "Function 'over_long_function' is 37 lines (max 30)"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
assert masked_message == "Function '<name>' is <number> lines (max <number>)"
|
|
591
|
+
|
|
592
|
+
def test_keeps_the_numbers_of_a_message_that_never_names_its_function(self) -> None:
|
|
593
|
+
magic_value_message = "Magic number 199 - use named constant"
|
|
594
|
+
|
|
595
|
+
masked_message = _scope_agnostic_message("compute_total", magic_value_message)
|
|
596
|
+
|
|
597
|
+
assert masked_message == magic_value_message
|
|
598
|
+
|
|
599
|
+
def test_keeps_a_module_scope_message_unchanged(self) -> None:
|
|
600
|
+
ruff_message = "F401 `os` imported but unused"
|
|
601
|
+
|
|
602
|
+
masked_message = _scope_agnostic_message("", ruff_message)
|
|
603
|
+
|
|
604
|
+
assert masked_message == ruff_message
|
|
605
|
+
|
|
606
|
+
def test_masks_only_the_innermost_name_of_a_qualified_scope(self) -> None:
|
|
607
|
+
masked_message = _scope_agnostic_message(
|
|
608
|
+
"Ledger.record_entry",
|
|
609
|
+
"Parameter 'entry' in 'record_entry' missing type annotation",
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
assert masked_message == "Parameter 'entry' in '<name>' missing type annotation"
|
|
613
|
+
|
|
614
|
+
|
|
454
615
|
class TestScopeNewAndPreexisting:
|
|
455
616
|
def test_failed_result_without_located_lines_is_treated_as_new(self) -> None:
|
|
456
617
|
summary_only_result = ValidatorResult(
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Repository-root anchoring tests for the PreToolUse staging walk.
|
|
2
|
+
|
|
3
|
+
Staging mirrors the destination's exemption-signalling directories through a
|
|
4
|
+
temp path so the save-path checks read the same signals the real path carries.
|
|
5
|
+
The walk must read only the segments the project itself owns. A directory above
|
|
6
|
+
the project root — a home directory named ``tests``, a checkout parked under
|
|
7
|
+
``config`` — belongs to whoever laid out the disk, not to the project, and must
|
|
8
|
+
never lend a production file a test or config exemption.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from .run_all_validators import (
|
|
16
|
+
_temporary_path_preserving_directory_signal,
|
|
17
|
+
validate_proposed_file,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
21
|
+
|
|
22
|
+
GIT_MARKER_DIRECTORY_NAME = ".git"
|
|
23
|
+
MAGIC_VALUE_CHECK_NAME = "Magic Values"
|
|
24
|
+
PRODUCTION_CONTENT_WITH_MAGIC_VALUE = "def retry_budget_seconds() -> int:\n return 3600 * 7\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _project_root_under(parent_directory: Path) -> Path:
|
|
28
|
+
"""Create a git-marked project directory under *parent_directory*.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
parent_directory: Directory the project root is created inside.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The created project root, holding an empty ``.git`` marker directory.
|
|
35
|
+
"""
|
|
36
|
+
project_root = parent_directory / "project"
|
|
37
|
+
(project_root / GIT_MARKER_DIRECTORY_NAME).mkdir(parents=True)
|
|
38
|
+
return project_root
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _staged_relative_path(staging_directory: Path, destination_path: Path) -> str:
|
|
42
|
+
"""Return the posix staged path *destination_path* produces, relative to staging.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
staging_directory: Root of the ephemeral staging tree.
|
|
46
|
+
destination_path: Real destination the write or edit targets.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The staged path relative to *staging_directory*, in posix form.
|
|
50
|
+
"""
|
|
51
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
52
|
+
staging_directory, str(destination_path)
|
|
53
|
+
)
|
|
54
|
+
return staged_path.relative_to(staging_directory).as_posix()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _failed_check_names(destination_path: Path, proposed_content: str) -> list[str]:
|
|
58
|
+
"""Return the names of every save-path check that fails for a destination.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
destination_path: Real destination the write or edit targets.
|
|
62
|
+
proposed_content: Post-edit content the write would leave on disk.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The failing check names, in roster order.
|
|
66
|
+
"""
|
|
67
|
+
return [
|
|
68
|
+
each_result.name
|
|
69
|
+
for each_result in validate_proposed_file(str(destination_path), proposed_content)
|
|
70
|
+
if not each_result.passed
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_drop_tests_directory_above_the_project_root(tmp_path: Path) -> None:
|
|
75
|
+
"""A home directory named ``tests`` lends no test exemption to project code."""
|
|
76
|
+
parent_named_tests = tmp_path / "tests"
|
|
77
|
+
project_root = _project_root_under(parent_named_tests)
|
|
78
|
+
staging_directory = tmp_path / "staging"
|
|
79
|
+
staging_directory.mkdir()
|
|
80
|
+
|
|
81
|
+
staged_relative = _staged_relative_path(staging_directory, project_root / "src" / "worker.py")
|
|
82
|
+
|
|
83
|
+
assert staged_relative == "worker.py", (
|
|
84
|
+
f"a tests directory above the project must not stage into the path, got {staged_relative}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_should_drop_config_directory_above_the_project_root(tmp_path: Path) -> None:
|
|
89
|
+
"""A checkout parked under ``config`` lends no config exemption to project code."""
|
|
90
|
+
parent_named_config = tmp_path / "config"
|
|
91
|
+
project_root = _project_root_under(parent_named_config)
|
|
92
|
+
staging_directory = tmp_path / "staging"
|
|
93
|
+
staging_directory.mkdir()
|
|
94
|
+
|
|
95
|
+
staged_relative = _staged_relative_path(staging_directory, project_root / "src" / "worker.py")
|
|
96
|
+
|
|
97
|
+
assert staged_relative == "worker.py", (
|
|
98
|
+
f"a config directory above the project must not stage into the path, got {staged_relative}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_report_magic_value_for_production_file_under_a_tests_parent(
|
|
103
|
+
tmp_path: Path,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""The magic-value check grades project code under a ``tests`` parent directory."""
|
|
106
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
107
|
+
|
|
108
|
+
all_failed_names = _failed_check_names(
|
|
109
|
+
project_root / "src" / "worker.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert MAGIC_VALUE_CHECK_NAME in all_failed_names, (
|
|
113
|
+
f"production code under a tests parent must still be graded, got {all_failed_names}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_report_magic_value_for_production_file_under_a_config_parent(
|
|
118
|
+
tmp_path: Path,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""The magic-value check grades project code under a ``config`` parent directory."""
|
|
121
|
+
project_root = _project_root_under(tmp_path / "config")
|
|
122
|
+
|
|
123
|
+
all_failed_names = _failed_check_names(
|
|
124
|
+
project_root / "src" / "worker.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert MAGIC_VALUE_CHECK_NAME in all_failed_names, (
|
|
128
|
+
f"production code under a config parent must still be graded, got {all_failed_names}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_should_keep_test_helpers_directory_inside_the_project(tmp_path: Path) -> None:
|
|
133
|
+
"""A ``test_helpers`` package inside the project keeps its test exemption."""
|
|
134
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
135
|
+
staging_directory = tmp_path / "staging"
|
|
136
|
+
staging_directory.mkdir()
|
|
137
|
+
|
|
138
|
+
staged_relative = _staged_relative_path(
|
|
139
|
+
staging_directory, project_root / "pkg" / "test_helpers" / "worker.py"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert staged_relative == "test_helpers/worker.py", (
|
|
143
|
+
f"an in-project test_helpers directory must stage into the path, got {staged_relative}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_should_keep_scripts_directory_inside_the_project(tmp_path: Path) -> None:
|
|
148
|
+
"""A ``scripts`` directory inside the project keeps its CLI-marker signal."""
|
|
149
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
150
|
+
staging_directory = tmp_path / "staging"
|
|
151
|
+
staging_directory.mkdir()
|
|
152
|
+
|
|
153
|
+
staged_relative = _staged_relative_path(
|
|
154
|
+
staging_directory, project_root / "scripts" / "deploy.py"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert staged_relative == "scripts/deploy.py", (
|
|
158
|
+
f"an in-project scripts directory must stage into the path, got {staged_relative}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_should_keep_tests_directory_inside_the_project(tmp_path: Path) -> None:
|
|
163
|
+
"""A genuine ``tests`` directory inside the project keeps its test exemption."""
|
|
164
|
+
project_root = _project_root_under(tmp_path / "config")
|
|
165
|
+
staging_directory = tmp_path / "staging"
|
|
166
|
+
staging_directory.mkdir()
|
|
167
|
+
|
|
168
|
+
staged_relative = _staged_relative_path(
|
|
169
|
+
staging_directory, project_root / "tests" / "helpers" / "data.py"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert staged_relative == "tests/helpers/data.py", (
|
|
173
|
+
f"an in-project tests directory must stage into the path, got {staged_relative}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_should_keep_config_directory_inside_the_project(tmp_path: Path) -> None:
|
|
178
|
+
"""A ``config`` directory inside the project keeps its magic-value exemption."""
|
|
179
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
180
|
+
|
|
181
|
+
all_failed_names = _failed_check_names(
|
|
182
|
+
project_root / "config" / "timing.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert MAGIC_VALUE_CHECK_NAME not in all_failed_names, (
|
|
186
|
+
f"an in-project config module must stay magic-value exempt, got {all_failed_names}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_should_walk_every_segment_for_a_target_with_no_project_root(
|
|
191
|
+
tmp_path: Path,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""An install-tree target outside every project keeps the whole-path walk."""
|
|
194
|
+
install_target = tmp_path / ".claude" / "hooks" / "blocking" / "gate.py"
|
|
195
|
+
staging_directory = tmp_path / "staging"
|
|
196
|
+
staging_directory.mkdir()
|
|
197
|
+
|
|
198
|
+
staged_relative = _staged_relative_path(staging_directory, install_target)
|
|
199
|
+
|
|
200
|
+
assert staged_relative == ".claude/hooks/blocking/gate.py", (
|
|
201
|
+
f"a target with no project root must keep its exemption tail, got {staged_relative}"
|
|
202
|
+
)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""Tests for shared validator base types."""
|
|
1
|
+
"""Tests for shared validator base types and its shared read and parse."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
2
5
|
|
|
3
6
|
import pytest
|
|
4
7
|
|
|
5
|
-
from .validator_base import Violation
|
|
8
|
+
from .validator_base import Violation, source_text, syntax_tree
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class TestViolation:
|
|
@@ -24,3 +27,49 @@ class TestViolation:
|
|
|
24
27
|
violation = Violation(file="test.py", line=42, message="Test message")
|
|
25
28
|
violation_set = {violation}
|
|
26
29
|
assert violation in violation_set
|
|
30
|
+
|
|
31
|
+
def test_source_text_reads_the_file_once_for_repeated_callers(tmp_path: Path) -> None:
|
|
32
|
+
"""Two callers asking for the same unchanged file cause one read."""
|
|
33
|
+
target = tmp_path / "module.py"
|
|
34
|
+
target.write_text("value = 1\n", encoding="utf-8")
|
|
35
|
+
first = source_text(target)
|
|
36
|
+
second = source_text(target)
|
|
37
|
+
assert first == second == "value = 1\n"
|
|
38
|
+
assert source_text.__module__ == "validators.validator_base"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_source_text_reads_again_after_the_file_changes(tmp_path: Path) -> None:
|
|
42
|
+
"""An edited file is read again rather than served from the cache.
|
|
43
|
+
|
|
44
|
+
The cache key carries the modification time and the size, so a rewrite
|
|
45
|
+
between two calls reaches disk a second time.
|
|
46
|
+
"""
|
|
47
|
+
target = tmp_path / "module.py"
|
|
48
|
+
target.write_text("value = 1\n", encoding="utf-8")
|
|
49
|
+
assert source_text(target) == "value = 1\n"
|
|
50
|
+
target.write_text("value = 2\nvalue = 3\n", encoding="utf-8")
|
|
51
|
+
assert source_text(target) == "value = 2\nvalue = 3\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_source_text_raises_for_a_missing_file(tmp_path: Path) -> None:
|
|
55
|
+
"""A caller's own error handling still sees the read failure."""
|
|
56
|
+
with pytest.raises(OSError):
|
|
57
|
+
source_text(tmp_path / "absent.py")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_syntax_tree_returns_the_same_tree_for_the_same_source() -> None:
|
|
61
|
+
"""One parse serves every caller asking about the same text."""
|
|
62
|
+
source = "def handler() -> int:\n return 1\n"
|
|
63
|
+
assert syntax_tree(source) is syntax_tree(source)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_syntax_tree_parses_what_ast_parse_parses() -> None:
|
|
67
|
+
"""The shared parse agrees with a direct ast.parse of the same text."""
|
|
68
|
+
source = "value = 1\n"
|
|
69
|
+
assert ast.dump(syntax_tree(source)) == ast.dump(ast.parse(source))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_syntax_tree_raises_syntax_error_for_unparseable_source() -> None:
|
|
73
|
+
"""A caller's own SyntaxError branch still fires."""
|
|
74
|
+
with pytest.raises(SyntaxError):
|
|
75
|
+
syntax_tree("def broken(:\n")
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import List
|
|
10
10
|
|
|
11
|
-
from .validator_base import Violation
|
|
11
|
+
from .validator_base import Violation, source_text
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
TODO_PATTERN = re.compile(r"#\s*(TODO|FIXME)\b(?!.*#\d+)", re.IGNORECASE)
|
|
@@ -34,7 +34,7 @@ def check_untracked_todos(source: str, filename: str) -> List[Violation]:
|
|
|
34
34
|
def validate_file(file_path: Path) -> List[Violation]:
|
|
35
35
|
filename = str(file_path)
|
|
36
36
|
try:
|
|
37
|
-
source = file_path
|
|
37
|
+
source = source_text(file_path)
|
|
38
38
|
except Exception as error:
|
|
39
39
|
return [Violation(filename, 0, f"Error reading file: {error}")]
|
|
40
40
|
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import List
|
|
12
12
|
|
|
13
|
-
from .validator_base import Violation
|
|
13
|
+
from .validator_base import Violation, source_text, syntax_tree
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def check_missing_type_hints(tree: ast.AST, filename: str) -> List[Violation]:
|
|
@@ -66,8 +66,8 @@ def validate_file(file_path: Path) -> List[Violation]:
|
|
|
66
66
|
filename = str(file_path)
|
|
67
67
|
|
|
68
68
|
try:
|
|
69
|
-
source = file_path
|
|
70
|
-
tree =
|
|
69
|
+
source = source_text(file_path)
|
|
70
|
+
tree = syntax_tree(source)
|
|
71
71
|
except Exception as error:
|
|
72
72
|
return [Violation(filename, 0, f"Error: {error}")]
|
|
73
73
|
|
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import List
|
|
11
11
|
|
|
12
|
-
from .validator_base import Violation
|
|
12
|
+
from .validator_base import Violation, source_text, syntax_tree
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def check_useless_tests(tree: ast.AST, filename: str) -> List[Violation]:
|
|
@@ -66,8 +66,8 @@ def validate_file(file_path: Path) -> List[Violation]:
|
|
|
66
66
|
return []
|
|
67
67
|
|
|
68
68
|
try:
|
|
69
|
-
source = file_path
|
|
70
|
-
tree =
|
|
69
|
+
source = source_text(file_path)
|
|
70
|
+
tree = syntax_tree(source)
|
|
71
71
|
except Exception as error:
|
|
72
72
|
return [Violation(filename, 0, f"Error: {error}")]
|
|
73
73
|
|