claude-dev-env 2.30.1 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +11 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_enforcer.py +57 -31
  13. package/hooks/blocking/codex_apply_patch.py +136 -21
  14. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  15. package/hooks/blocking/conftest.py +70 -0
  16. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  17. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  18. package/hooks/blocking/pii_payload_scan.py +61 -0
  19. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  20. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  22. package/hooks/blocking/sensitive_file_protector.py +18 -3
  23. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  24. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  25. package/hooks/blocking/state_description_blocker.py +39 -26
  26. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  27. package/hooks/blocking/tdd_enforcer.py +57 -14
  28. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  29. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  30. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  31. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  32. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  33. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  36. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  38. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  40. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  41. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  42. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  45. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  46. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  48. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  49. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  50. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  51. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  52. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  53. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  54. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  55. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  56. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  57. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  58. package/hooks/blocking/test_state_description_blocker.py +68 -0
  59. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  60. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  61. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  62. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  63. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  64. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  65. package/hooks/conftest.py +47 -0
  66. package/hooks/hooks.json +7 -12
  67. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  68. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  70. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  71. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  73. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  74. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  75. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  76. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  77. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  78. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  81. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  83. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  84. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  85. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  86. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  87. package/hooks/observability/session_file_edit_tracker.py +25 -4
  88. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  89. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  90. package/hooks/validation/hook_format_validator.py +23 -9
  91. package/hooks/validation/test_hook_format_validator.py +58 -0
  92. package/hooks/validators/README.md +1 -1
  93. package/hooks/validators/abbreviation_checks.py +3 -5
  94. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  95. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  96. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  97. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  98. package/hooks/validators/fast_save_validators.py +332 -0
  99. package/hooks/validators/hook_timing_harness.py +8 -4
  100. package/hooks/validators/python_style_checks.py +34 -60
  101. package/hooks/validators/python_style_helpers.py +16 -2
  102. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  103. package/hooks/validators/run_all_validators.py +42 -41
  104. package/hooks/validators/test_abbreviation_checks.py +20 -0
  105. package/hooks/validators/test_fast_save_validators.py +172 -0
  106. package/hooks/validators/test_python_style_checks.py +9 -0
  107. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  108. package/hooks/validators/test_python_style_helpers.py +35 -0
  109. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  110. package/hooks/validators/test_run_all_validators.py +46 -0
  111. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  112. package/package.json +1 -1
  113. package/rules/docstring-prose-matches-implementation.md +1 -1
  114. package/rules/git-workflow.md +2 -0
  115. package/rules/testing.md +4 -0
  116. package/scripts/codex_compat_materializer.py +5 -0
  117. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  118. package/hooks/blocking/tests/conftest.py +0 -10
@@ -24,6 +24,7 @@ from .config.directory_exemption_constants import (
24
24
  ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES,
25
25
  ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS,
26
26
  )
27
+ from .fast_save_validators import FastSaveCheckOutcome, run_fast_save_validators
27
28
  from .health_check import get_system_health, get_validator_version, print_health_report
28
29
  from .mypy_integration import check_mypy_available, run_mypy_check
29
30
  from .system_temporary_roots import enclosing_system_temporary_root
@@ -42,6 +43,35 @@ hooks_dir = VALIDATORS_DIR.parent
42
43
  package_name = VALIDATORS_DIR.name
43
44
 
44
45
 
46
+ def _gate_entry_for_fast_save_check(
47
+ check_outcome: FastSaveCheckOutcome,
48
+ ) -> "ValidatorResult":
49
+ """Wrap one in-process check's finding into the gate's shared reporting type."""
50
+ return ValidatorResult(
51
+ name=check_outcome.display_name,
52
+ checks=check_outcome.checks,
53
+ passed=check_outcome.is_clean,
54
+ output=check_outcome.violation_report,
55
+ )
56
+
57
+
58
+ def _run_fast_save_checks(files: List[Path]) -> List["ValidatorResult"]:
59
+ """Run the save-path roster in-process and wrap each finding for the gate.
60
+
61
+ Args:
62
+ files: The files under validation -- one reconstructed file in gate mode.
63
+
64
+ Returns:
65
+ One ValidatorResult per in-process save-path check. Ruff and Mypy are
66
+ not part of this roster; the caller adds Ruff separately and never
67
+ adds Mypy on the save path.
68
+ """
69
+ return [
70
+ _gate_entry_for_fast_save_check(each_outcome)
71
+ for each_outcome in run_fast_save_validators(files)
72
+ ]
73
+
74
+
45
75
  def _windows_non_unc_working_directory_string(
46
76
  candidate_directory_strings: list[str | None],
47
77
  ) -> str:
@@ -731,38 +761,6 @@ def reconstruct_proposed_content(
731
761
  return apply_edits(existing_content, edits_for_tool(tool_name, dict(tool_input)))
732
762
 
733
763
 
734
- def run_file_scoped_validators(
735
- all_files: List[Path], config_source_path: Optional[Path] = None
736
- ) -> List[ValidatorResult]:
737
- """Run every file-scoped validator against *all_files*.
738
-
739
- Excludes the branch-scoped File Structure and Git validators.
740
-
741
- Args:
742
- all_files: The files under validation — one reconstructed file in gate mode.
743
- config_source_path: Original path ruff and mypy resolve their config from.
744
-
745
- Returns:
746
- One ValidatorResult per file-scoped validator, in run order.
747
- """
748
- return [
749
- run_python_style_checks(all_files),
750
- run_test_safety_checks(all_files),
751
- run_react_checks(all_files),
752
- run_ruff_checks(all_files, config_source_path),
753
- run_mypy_checks(all_files, config_source_path),
754
- run_abbreviation_checks(all_files),
755
- run_pr_reference_checks(all_files),
756
- run_magic_value_checks(all_files),
757
- run_useless_test_checks(all_files),
758
- run_security_checks(all_files),
759
- run_code_quality_checks(all_files),
760
- run_python_antipattern_checks(all_files),
761
- run_todo_checks(all_files),
762
- run_type_safety_checks(all_files),
763
- ]
764
-
765
-
766
764
  def _escapes_temporary_root(path_part: str) -> bool:
767
765
  """Return True when a path part would climb out of or re-anchor the temp root.
768
766
 
@@ -919,18 +917,20 @@ def validate_proposed_file(
919
917
  Writes the content to a temporary file that preserves the exemption-relevant
920
918
  directory tail and basename so directory-based exemptions, suffix-based
921
919
  filtering, and test-name-based filtering match the real path, then runs the
922
- file-scoped validators against it. Ruff and mypy resolve their config by
923
- walking up from the config source path, so the staged copy is graded under
924
- the project config the real path sits in rather than the temp directory's.
920
+ save-path checks in-process against it, plus Ruff. Ruff resolves its config
921
+ by walking up from the config source path, so the staged copy is graded
922
+ under the project config the real path sits in rather than the temp
923
+ directory's.
925
924
 
926
925
  Args:
927
926
  file_path: The destination path the write or edit targets.
928
927
  proposed_content: The reconstructed post-edit content of that file.
929
- config_source_path: Path ruff and mypy resolve their config from;
930
- defaults to *file_path* when the caller passes nothing.
928
+ config_source_path: Path Ruff resolves its config from; defaults to
929
+ *file_path* when the caller passes nothing.
931
930
 
932
931
  Returns:
933
- One ValidatorResult per file-scoped validator.
932
+ One ValidatorResult per save-path check, plus Ruff. Mypy never runs
933
+ here; CLI/full mode is the only caller that still runs it.
934
934
  """
935
935
  with tempfile.TemporaryDirectory() as temporary_directory:
936
936
  temporary_file = _temporary_path_preserving_directory_signal(
@@ -942,9 +942,10 @@ def validate_proposed_file(
942
942
  if config_source_path is not None
943
943
  else Path(file_path)
944
944
  )
945
- return run_file_scoped_validators(
946
- [temporary_file], config_source_path=resolved_config_source_path
947
- )
945
+ return [
946
+ *_run_fast_save_checks([temporary_file]),
947
+ run_ruff_checks([temporary_file], resolved_config_source_path),
948
+ ]
948
949
 
949
950
 
950
951
  def _validator_summaries(results: List[ValidatorResult]) -> str:
@@ -74,3 +74,23 @@ class TestSingleLetterVariables:
74
74
  tree = ast.parse(ALLOWED_LOOP_COUNTERS)
75
75
  violations = check_single_letter_variables(tree, "test.py")
76
76
  assert violations == []
77
+
78
+ def test_underscore_allowed_alongside_disallowed_single_letter(self) -> None:
79
+ """``_`` and the loop counters share one allow-list; every other letter fails.
80
+
81
+ config/abbreviation_checks_constants.py pins the allow-list's exact
82
+ membership; this test pins that check_single_letter_variables reads
83
+ that same list correctly for a mixed allowed/disallowed source.
84
+ """
85
+ source = '''
86
+ def process(matrix):
87
+ for i in range(10):
88
+ for j in range(10):
89
+ for k in range(10):
90
+ _ = matrix[i][j][k]
91
+ q = matrix[i][j][k]
92
+ '''
93
+ tree = ast.parse(source)
94
+ violations = check_single_letter_variables(tree, "test.py")
95
+ assert len(violations) == 1
96
+ assert "q" in violations[0].message
@@ -0,0 +1,172 @@
1
+ """Tests for fast_save_validators.py -- the in-process save-path validator roster."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from .fast_save_validators import run_fast_save_validators
9
+ from .magic_value_checks import validate_file as read_magic_literal_violations
10
+ from .react_checks import check_no_class_components
11
+ from .test_safety_checks import check_no_skip_decorators
12
+ from .todo_checks import validate_file as read_todo_violations
13
+
14
+ MAGIC_LITERAL_SOURCE = "def compute_total(unit_price: int) -> int:\n return unit_price * 199\n"
15
+ _UNTRACKED_TODO_MARKER = "#" + " TODO"
16
+ TODO_SOURCE = f"{_UNTRACKED_TODO_MARKER}: revisit this\ndef clean() -> None:\n return None\n"
17
+ REACT_CLASS_COMPONENT_SOURCE = "class Widget extends Component {\n render() { return null; }\n}\n"
18
+ TEST_SKIP_DECORATOR_SOURCE = (
19
+ "import pytest\n\n\n@pytest.mark.skip\ndef test_something() -> None:\n pass\n"
20
+ )
21
+
22
+ _ALL_EXPECTED_FAST_SAVE_VALIDATOR_NAMES = frozenset(
23
+ {
24
+ "Python Style",
25
+ "Abbreviations",
26
+ "Magic Values",
27
+ "TODO Tracking",
28
+ "Security",
29
+ "Code Quality",
30
+ "Python Anti-patterns",
31
+ "Type Safety",
32
+ "Test Safety",
33
+ "Useless Tests",
34
+ "React",
35
+ "PR References",
36
+ }
37
+ )
38
+
39
+
40
+ def test_run_fast_save_validators_covers_every_non_mypy_non_ruff_check(
41
+ tmp_path: Path,
42
+ ) -> None:
43
+ target_file = tmp_path / "legacy_module.py"
44
+ target_file.write_text(MAGIC_LITERAL_SOURCE, encoding="utf-8")
45
+
46
+ all_outcomes = run_fast_save_validators([target_file])
47
+
48
+ assert {each_outcome.display_name for each_outcome in all_outcomes} == (
49
+ _ALL_EXPECTED_FAST_SAVE_VALIDATOR_NAMES
50
+ )
51
+
52
+
53
+ def test_run_fast_save_validators_excludes_mypy_and_ruff(tmp_path: Path) -> None:
54
+ target_file = tmp_path / "legacy_module.py"
55
+ target_file.write_text(MAGIC_LITERAL_SOURCE, encoding="utf-8")
56
+
57
+ all_outcomes = run_fast_save_validators([target_file])
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)
61
+
62
+
63
+ def test_run_fast_save_validators_spawns_no_subprocess(
64
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
65
+ ) -> None:
66
+ target_file = tmp_path / "legacy_module.py"
67
+ target_file.write_text(MAGIC_LITERAL_SOURCE, encoding="utf-8")
68
+
69
+ def _fail_if_called(*args: object, **kwargs: object) -> None:
70
+ raise AssertionError("run_fast_save_validators must not spawn a subprocess")
71
+
72
+ monkeypatch.setattr("subprocess.run", _fail_if_called)
73
+
74
+ run_fast_save_validators([target_file])
75
+
76
+
77
+ def test_magic_values_outcome_matches_direct_validate_file_call(
78
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
79
+ ) -> None:
80
+ # A directory with no "test_"-patterned segment: magic_value_checks exempts
81
+ # any path under one, which pytest's own tmp_path always carries (tmp_path
82
+ # names itself "test_<function name><N>"). tmp_path's own parent carries no
83
+ # such segment. Routing TMPDIR there through monkeypatch, and also passing
84
+ # it as TemporaryDirectory's own ``dir``, keeps the probe from depending on
85
+ # whatever the real process environment happens to hold.
86
+ isolated_root = tmp_path.parent
87
+ monkeypatch.setenv("TMPDIR", str(isolated_root))
88
+ with tempfile.TemporaryDirectory(
89
+ dir=str(isolated_root), prefix="fast_save_probe_"
90
+ ) as workspace_name:
91
+ target_file = Path(workspace_name) / "legacy_module.py"
92
+ target_file.write_text(MAGIC_LITERAL_SOURCE, encoding="utf-8")
93
+ expected_lines = [
94
+ str(each) for each in read_magic_literal_violations(target_file)
95
+ ]
96
+ assert expected_lines != []
97
+
98
+ all_outcomes = run_fast_save_validators([target_file])
99
+
100
+ magic_values_outcome = next(
101
+ each for each in all_outcomes if each.display_name == "Magic Values"
102
+ )
103
+ assert magic_values_outcome.violation_report == "\n".join(expected_lines)
104
+
105
+
106
+ def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
107
+ target_file = tmp_path / "legacy_module.py"
108
+ target_file.write_text(TODO_SOURCE, encoding="utf-8")
109
+ expected_lines = [str(each) for each in read_todo_violations(target_file)]
110
+ assert expected_lines != []
111
+
112
+ all_outcomes = run_fast_save_validators([target_file])
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)
116
+
117
+
118
+ def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) -> None:
119
+ target_file = tmp_path / "legacy_module.py"
120
+ target_file.write_text(
121
+ "def add_two_numbers(first: int, second: int) -> int:\n return first + second\n",
122
+ encoding="utf-8",
123
+ )
124
+
125
+ all_outcomes = run_fast_save_validators([target_file])
126
+
127
+ abbreviations_outcome = next(
128
+ each for each in all_outcomes if each.display_name == "Abbreviations"
129
+ )
130
+ assert abbreviations_outcome.is_clean is True
131
+
132
+
133
+ def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> None:
134
+ target_file = tmp_path / "widget.tsx"
135
+ target_file.write_text(REACT_CLASS_COMPONENT_SOURCE, encoding="utf-8")
136
+ expected_violations = check_no_class_components([str(target_file)])
137
+ expected_lines = [f"{each.file}:{each.line}: {each.message}" for each in expected_violations]
138
+ assert expected_lines != []
139
+
140
+ all_outcomes = run_fast_save_validators([target_file])
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)
144
+
145
+
146
+ def test_test_safety_outcome_matches_direct_check_function_calls(
147
+ tmp_path: Path,
148
+ ) -> None:
149
+ target_file = tmp_path / "test_something.py"
150
+ target_file.write_text(TEST_SKIP_DECORATOR_SOURCE, encoding="utf-8")
151
+ code = target_file.read_text(encoding="utf-8")
152
+ expected_violations = check_no_skip_decorators(code, str(target_file))
153
+ expected_lines = [str(each) for each in expected_violations]
154
+ assert expected_lines != []
155
+
156
+ all_outcomes = run_fast_save_validators([target_file])
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)
160
+
161
+
162
+ def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
163
+ target_file = tmp_path / "widget.tsx"
164
+ target_file.write_text("export const Widget = () => null;\n", encoding="utf-8")
165
+
166
+ all_outcomes = run_fast_save_validators([target_file])
167
+
168
+ abbreviations_outcome = next(
169
+ each for each in all_outcomes if each.display_name == "Abbreviations"
170
+ )
171
+ assert abbreviations_outcome.is_clean is True
172
+ assert abbreviations_outcome.violation_report == "No Python files to check"
@@ -197,6 +197,15 @@ class TestImportsAtTop:
197
197
  assert len(violations) == 1
198
198
  assert "inside function" in violations[0].message.lower()
199
199
 
200
+ def test_sys_path_bootstrap_guard_does_not_fail(self) -> None:
201
+ """The mandated sys.path dedup-guard idiom should not fail (see test_python_style_import_bootstrap.py)."""
202
+ tree = ast.parse(
203
+ "import sys\n_hooks_dir = str(__file__)\n"
204
+ "if _hooks_dir not in sys.path:\n sys.path.insert(0, _hooks_dir)\n"
205
+ "from hooks_constants.something import NAME # noqa: E402\n"
206
+ )
207
+ assert check_imports_at_top(tree, "test.py") == []
208
+
200
209
 
201
210
  class TestNoEmptyLineAfterDecorators:
202
211
  """Test decorator spacing validation."""
@@ -0,0 +1,81 @@
1
+ """Tests for the sys.path bootstrap-guard idiom in the import-order check.
2
+
3
+ Split from ``test_python_style_checks.py`` to stay under its line cap,
4
+ matching the existing ``test_python_style_checks_decorator_gap.py`` split.
5
+ """
6
+
7
+ import ast
8
+ from pathlib import Path
9
+
10
+ from .python_style_checks import check_imports_at_top
11
+
12
+ SYS_PATH_BOOTSTRAP_GUARD_IMPORTS = """import sys
13
+ from pathlib import Path
14
+
15
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
16
+ if _hooks_dir not in sys.path:
17
+ sys.path.insert(0, _hooks_dir)
18
+
19
+ from hooks_constants.something import NAME # noqa: E402
20
+ """
21
+
22
+ TWO_SYS_PATH_BOOTSTRAP_GUARD_BLOCKS = """import sys
23
+ from pathlib import Path
24
+
25
+ _first_dir = str(Path(__file__).resolve().parent)
26
+ if _first_dir not in sys.path:
27
+ sys.path.insert(0, _first_dir)
28
+
29
+ from hooks_constants.first import FIRST_NAME # noqa: E402
30
+
31
+ _second_dir = str(Path(__file__).resolve().parent.parent)
32
+ if _second_dir not in sys.path:
33
+ sys.path.insert(0, _second_dir)
34
+
35
+ from hooks_constants.second import SECOND_NAME # noqa: E402
36
+ """
37
+
38
+ UNGUARDED_SYS_PATH_INSERT_THEN_IMPORT = """import sys
39
+
40
+ sys.path.insert(0, "/some/hooks/dir")
41
+
42
+ import os
43
+ """
44
+
45
+
46
+ class TestSysPathBootstrapGuardImportOrder:
47
+ """Tests pinning the sys.path bootstrap-guard exemption in import ordering."""
48
+
49
+ def test_sys_path_bootstrap_guard_passes(self) -> None:
50
+ """The mandated dedup-guard idiom must not flag its own import."""
51
+ tree = ast.parse(SYS_PATH_BOOTSTRAP_GUARD_IMPORTS)
52
+ violations = check_imports_at_top(tree, "test.py")
53
+ assert violations == []
54
+
55
+ def test_two_sys_path_bootstrap_guard_blocks_pass(self) -> None:
56
+ """Two separate bootstrap-guard blocks in one file both pass."""
57
+ tree = ast.parse(TWO_SYS_PATH_BOOTSTRAP_GUARD_BLOCKS)
58
+ violations = check_imports_at_top(tree, "test.py")
59
+ assert violations == []
60
+
61
+ def test_unguarded_sys_path_insert_still_fails(self) -> None:
62
+ """A bare sys.path.insert with no membership guard still counts as ordinary code.
63
+
64
+ Only the guarded idiom `check_sys_path_insert_deduplication_guard`
65
+ mandates is exempt. An unguarded insert is itself a violation of that
66
+ same guard, so an import after it stays flagged rather than rewarding
67
+ the shape the other gate penalizes.
68
+ """
69
+ tree = ast.parse(UNGUARDED_SYS_PATH_INSERT_THEN_IMPORT)
70
+ violations = check_imports_at_top(tree, "test.py")
71
+ assert len(violations) == 1
72
+ assert violations[0].line == 5
73
+
74
+ def test_code_rules_enforcer_bootstrap_produces_no_findings(self) -> None:
75
+ """The enforcer's own real bootstrap block goes from 29 findings to 0."""
76
+ enforcer_path = (
77
+ Path(__file__).resolve().parent.parent / "blocking" / "code_rules_enforcer.py"
78
+ )
79
+ tree = ast.parse(enforcer_path.read_text(encoding="utf-8"))
80
+ violations = check_imports_at_top(tree, str(enforcer_path))
81
+ assert violations == []
@@ -6,11 +6,18 @@ from .python_style_helpers import (
6
6
  blank_line_for_source,
7
7
  function_start_line,
8
8
  gap_is_blank_only,
9
+ is_docstring_statement,
10
+ is_import_statement,
9
11
  iter_function_definitions,
10
12
  real_newline_lines,
11
13
  top_level_functions,
12
14
  )
13
15
 
16
+
17
+ def _first_statement(source: str) -> ast.stmt:
18
+ """Return the first top-level statement of *source*."""
19
+ return ast.parse(source).body[0]
20
+
14
21
  TWO_FUNCTIONS = """def foo() -> None:
15
22
  pass
16
23
 
@@ -123,3 +130,31 @@ class TestBlankLineForSource:
123
130
  def test_crlf_source_returns_crlf(self) -> None:
124
131
  """CRLF source yields a CRLF blank line."""
125
132
  assert blank_line_for_source(CRLF_SOURCE) == "\r\n"
133
+
134
+
135
+ class TestIsImportStatement:
136
+ """Tests pinning which statements the import-order scan treats as imports."""
137
+
138
+ def test_import_statement_is_recognized(self) -> None:
139
+ """A plain `import` statement is recognized."""
140
+ assert is_import_statement(_first_statement("import os\n")) is True
141
+
142
+ def test_from_import_statement_is_recognized(self) -> None:
143
+ """A `from ... import ...` statement is recognized."""
144
+ assert is_import_statement(_first_statement("from os import path\n")) is True
145
+
146
+ def test_assignment_is_not_an_import_statement(self) -> None:
147
+ """An assignment is not treated as an import."""
148
+ assert is_import_statement(_first_statement("X = 1\n")) is False
149
+
150
+
151
+ class TestIsDocstringStatement:
152
+ """Tests pinning which statements the import-order scan treats as docstrings."""
153
+
154
+ def test_leading_string_literal_is_a_docstring(self) -> None:
155
+ """A bare string-literal expression statement is a docstring."""
156
+ assert is_docstring_statement(_first_statement('"""A module docstring."""\n')) is True
157
+
158
+ def test_assignment_is_not_a_docstring(self) -> None:
159
+ """An assignment is not a docstring, even with a string value."""
160
+ assert is_docstring_statement(_first_statement('X = "not a docstring"\n')) is False
@@ -0,0 +1,69 @@
1
+ """Tests for the sys.path bootstrap-guard idiom recognizer."""
2
+
3
+ import ast
4
+
5
+ from .python_style_import_bootstrap import is_sys_path_bootstrap_prelude
6
+
7
+
8
+ def _module_body(source: str) -> list[ast.stmt]:
9
+ """Return the top-level statements of *source* for use as a pending run."""
10
+ return ast.parse(source).body
11
+
12
+
13
+ class TestIsSysPathBootstrapPrelude:
14
+ """Tests pinning which statement runs qualify as the bootstrap prelude."""
15
+
16
+ def test_empty_run_is_not_a_prelude(self) -> None:
17
+ """An empty run carries no guard, so it is not the bootstrap idiom."""
18
+ assert is_sys_path_bootstrap_prelude([]) is False
19
+
20
+ def test_guard_alone_is_a_prelude(self) -> None:
21
+ """A bare guarded insert, with no preceding assignment, still counts."""
22
+ body = _module_body(
23
+ 'if "/some/dir" not in sys.path:\n sys.path.insert(0, "/some/dir")\n'
24
+ )
25
+ assert is_sys_path_bootstrap_prelude(body) is True
26
+
27
+ def test_assignment_feeding_guard_is_a_prelude(self) -> None:
28
+ """The mandated `path = ...` plus guarded insert pair passes."""
29
+ body = _module_body(
30
+ "_hooks_dir = str(Path(__file__).resolve().parent)\n"
31
+ "if _hooks_dir not in sys.path:\n"
32
+ " sys.path.insert(0, _hooks_dir)\n"
33
+ )
34
+ assert is_sys_path_bootstrap_prelude(body) is True
35
+
36
+ def test_unrelated_trailing_constant_stays_a_prelude(self) -> None:
37
+ """A plain constant sitting alongside a real guard does not break it.
38
+
39
+ Mirrors code_rules_enforcer.py's own bootstrap block, which tucks an
40
+ unrelated `_codex_apply_patch_tool_name = "apply_patch"` constant
41
+ right after its guarded sys.path.insert calls.
42
+ """
43
+ body = _module_body(
44
+ "_hooks_dir = str(Path(__file__).resolve().parent)\n"
45
+ "if _hooks_dir not in sys.path:\n"
46
+ " sys.path.insert(0, _hooks_dir)\n"
47
+ '_codex_apply_patch_tool_name = "apply_patch"\n'
48
+ )
49
+ assert is_sys_path_bootstrap_prelude(body) is True
50
+
51
+ def test_plain_assignments_with_no_guard_are_not_a_prelude(self) -> None:
52
+ """Assignments alone, with no guard anywhere, are ordinary code."""
53
+ body = _module_body("MY_CONSTANT = 42\n")
54
+ assert is_sys_path_bootstrap_prelude(body) is False
55
+
56
+ def test_unguarded_insert_is_not_a_prelude(self) -> None:
57
+ """A bare sys.path.insert with no membership guard is ordinary code."""
58
+ body = _module_body('sys.path.insert(0, "/some/dir")\n')
59
+ assert is_sys_path_bootstrap_prelude(body) is False
60
+
61
+ def test_guard_alongside_a_function_def_is_not_a_prelude(self) -> None:
62
+ """A real statement (a function def) in the run still counts as ordinary code."""
63
+ body = _module_body(
64
+ 'if "/some/dir" not in sys.path:\n'
65
+ ' sys.path.insert(0, "/some/dir")\n'
66
+ "def foo() -> None:\n"
67
+ " pass\n"
68
+ )
69
+ assert is_sys_path_bootstrap_prelude(body) is False
@@ -31,6 +31,10 @@ _ASSERT_FALSE_SOURCE = (
31
31
  " assert False, observed_total\n"
32
32
  )
33
33
  PRINT_PRODUCTION_CONTENT = 'def run() -> None:\n print("x")\n'
34
+ CLEAN_PROBE_SOURCE = (
35
+ "def add_two_numbers(first_number: int, second_number: int) -> int:\n"
36
+ " return first_number + second_number\n"
37
+ )
34
38
  ALL_PYTHON_TEST_EXEMPT_PATHS = [
35
39
  "pkg/test_helpers/worker.py",
36
40
  "pkg/conftest.py",
@@ -66,6 +70,48 @@ def test_validate_proposed_file_threads_ruff_config_from_original_path() -> None
66
70
  assert "B011" in ruff_output
67
71
 
68
72
 
73
+ def test_validate_proposed_file_never_probes_mypy_availability() -> None:
74
+ """The save-path gate drops Mypy entirely; it must not even probe for it.
75
+
76
+ Mypy runs 500-1800ms cold, the largest single item on the save path by a
77
+ wide margin (see hooks/validators/hook_timing_harness.py). CLI/full mode
78
+ keeps probing it; only the save-path gate skips the probe.
79
+ """
80
+ with patch("validators.run_all_validators.check_mypy_available") as mock_check_mypy:
81
+ validate_proposed_file(
82
+ str(_VALIDATORS_DIRECTORY / "probe_target.py"), CLEAN_PROBE_SOURCE
83
+ )
84
+
85
+ mock_check_mypy.assert_not_called()
86
+
87
+
88
+ def test_validate_proposed_file_never_spawns_a_validator_subprocess() -> None:
89
+ """The save-path gate must not spawn a ``python -m validators.<module>`` per check.
90
+
91
+ Twelve validator subprocesses cost roughly 600ms in interpreter startup
92
+ and import alone (see hook_timing_harness.py), for checks that measure in
93
+ single digits once running. CLI/full mode keeps spawning one process per
94
+ validator; only the save-path gate runs them in-process instead.
95
+ """
96
+ with patch("validators.run_all_validators.invoke_validator_module") as mock_invoke:
97
+ validate_proposed_file(
98
+ str(_VALIDATORS_DIRECTORY / "probe_target.py"), CLEAN_PROBE_SOURCE
99
+ )
100
+
101
+ mock_invoke.assert_not_called()
102
+
103
+
104
+ def test_validate_proposed_file_still_finds_a_fast_path_violation() -> None:
105
+ """Dropping mypy and the per-validator subprocess must not blind the gate."""
106
+ all_results = validate_proposed_file(
107
+ str(_VALIDATORS_DIRECTORY / "probe_target.py"), PRINT_PRODUCTION_CONTENT
108
+ )
109
+
110
+ failed_names = {each_result.name for each_result in all_results if not each_result.passed}
111
+ assert failed_names, "expected at least one failing fast-path check"
112
+ assert "Mypy" not in failed_names
113
+
114
+
69
115
  def _validators_deny_print(file_path: str) -> bool:
70
116
  all_results = validate_proposed_file(file_path, PRINT_PRODUCTION_CONTENT)
71
117
  return any(not each_result.passed for each_result in all_results)
@@ -26,6 +26,8 @@ from .run_all_validators import (
26
26
  run_validators_entrypoint_subprocess,
27
27
  )
28
28
 
29
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
30
+
29
31
  CONFIG_DIR_TARGET_PATH = (
30
32
  "CDP Automations/os_update_workflow/config/submission_constants.py"
31
33
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.30.1",
3
+ "version": "2.31.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@ Many deterministic shapes of this drift have Write/Edit gates in `~/.claude/hook
34
34
 
35
35
  A hook module is the sharpest case of the same rule: its docstring lead narrative and its `CORRECTIVE_MESSAGE` describe exactly the shapes the detector flags, and claim no broader trigger surface than the regex enforces.
36
36
 
37
- `hook_prose_detector_consistency` (PreToolUse on Write|Edit of hook modules and their `*_constants.py` companions) blocks prose that claims a trigger the detector never fires on, and names the fix.
37
+ `hook_prose_detector_consistency` (PreToolUse on Write|Edit|MultiEdit of hook modules and their `*_constants.py` companions) blocks prose that claims a trigger the detector never fires on, and names the fix.
38
38
 
39
39
  After writing a hook, ask: would a token matching every word of this message actually trip the detector? When the message names a shape the regex skips, rewrite the message to name only what the regex catches. The path-shape case is the common overstatement — a detector that keys off a path separator must not claim it blocks an "output-key segment". The corrective message spells the rewrite.
40
40
 
@@ -17,6 +17,8 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh` (no ex
17
17
 
18
18
  **ALWAYS create PRs as DRAFT:** Use `gh pr create --draft` for ALL PRs
19
19
 
20
+ **The pr-description-writer agent writes the body:** Spawn the `pr-description-writer` agent (Agent tool, `subagent_type` `pr-description-writer`), then publish what it returns with `--body-file`. `pr_description_writer_gate.py` (PreToolUse on Bash and PowerShell, hosted by `bash_pre_tool_use_dispatcher`) denies a `gh pr create` when the session records no such spawn. A trailing `# pr-description-skip` comment on the command opts out for a body you write yourself.
21
+
20
22
  ## Git Golden Rules (NON-NEGOTIABLE)
21
23
 
22
24
  1. **DRAFT BEFORE PUSH**: When pushing ANYTHING to a PR, it MUST be in draft state first
package/rules/testing.md CHANGED
@@ -22,3 +22,7 @@ Incomplete mocks make it impossible to distinguish "broken code" from "missing d
22
22
  ## Tests Exercise Real Behavior
23
23
 
24
24
  Tests exercise real behavior, real data, and production code paths. A test that asserts on a stand-in for the production path proves the stand-in works.
25
+
26
+ ## The File-Level TDD Gate Reads Content
27
+
28
+ `tdd_enforcer.py` records each candidate test's content hash at every sighting and compares that hash on the next write. A first sighting requires content that differs from HEAD.