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.
Files changed (47) hide show
  1. package/docs/CODE_RULES.md +2 -1
  2. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  3. package/hooks/blocking/code_rules_enforcer.py +32 -14
  4. package/hooks/blocking/code_rules_shared.py +64 -14
  5. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  6. package/hooks/blocking/tdd_enforcer.py +8 -4
  7. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  8. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  9. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  10. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  11. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  12. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -2
  13. package/hooks/blocking/test_state_description_blocker.py +99 -53
  14. package/hooks/blocking/test_tdd_enforcer.py +64 -0
  15. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  16. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  17. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  18. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  19. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  20. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  21. package/hooks/validators/abbreviation_checks.py +3 -3
  22. package/hooks/validators/code_quality_checks.py +4 -4
  23. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  24. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  25. package/hooks/validators/config/validator_base_constants.py +9 -0
  26. package/hooks/validators/fast_save_validators.py +23 -80
  27. package/hooks/validators/pr_reference_checks.py +2 -2
  28. package/hooks/validators/python_antipattern_checks.py +3 -3
  29. package/hooks/validators/python_style_checks.py +3 -2
  30. package/hooks/validators/react_checks.py +2 -9
  31. package/hooks/validators/run_all_validators.py +190 -145
  32. package/hooks/validators/security_checks.py +3 -3
  33. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  34. package/hooks/validators/test_fast_save_validators.py +16 -16
  35. package/hooks/validators/test_hook_timing_harness.py +10 -1
  36. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  37. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  38. package/hooks/validators/test_validator_base.py +51 -2
  39. package/hooks/validators/todo_checks.py +2 -2
  40. package/hooks/validators/type_safety_checks.py +3 -3
  41. package/hooks/validators/useless_test_checks.py +3 -3
  42. package/hooks/validators/validator_base.py +73 -1
  43. package/package.json +1 -1
  44. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  45. package/scripts/grok_headless_runner.py +15 -12
  46. package/scripts/shared_tree_paths.py +70 -0
  47. 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.display_name for each_outcome in all_outcomes} == (
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.display_name != "Mypy" for each_outcome in all_outcomes)
60
- assert all(each_outcome.display_name != "Ruff" for each_outcome in all_outcomes)
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.display_name == "Magic Values"
101
+ each for each in all_outcomes if each.name == "Magic Values"
102
102
  )
103
- assert magic_values_outcome.violation_report == "\n".join(expected_lines)
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.display_name == "TODO Tracking")
115
- assert todo_outcome.violation_report == "\n".join(expected_lines)
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.display_name == "Abbreviations"
128
+ each for each in all_outcomes if each.name == "Abbreviations"
129
129
  )
130
- assert abbreviations_outcome.is_clean is True
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.display_name == "React")
143
- assert react_outcome.violation_report == "\n".join(expected_lines)
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.display_name == "Test Safety")
159
- assert test_safety_outcome.violation_report == "\n".join(expected_lines)
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.display_name == "Abbreviations"
169
+ each for each in all_outcomes if each.name == "Abbreviations"
170
170
  )
171
- assert abbreviations_outcome.is_clean is True
172
- assert abbreviations_outcome.violation_report == "No Python files to check"
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.read_text(encoding="utf-8")
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.read_text(encoding="utf-8")
70
- tree = ast.parse(source)
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.read_text(encoding="utf-8")
70
- tree = ast.parse(source)
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