claude-dev-env 2.30.1 → 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 (143) 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 +12 -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_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -8,8 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  from pathlib import Path
10
10
 
11
- import pytest
12
-
13
11
  from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
14
12
  from hooks_constants.code_rules_path_utils_constants import ALL_CONFIG_DIRECTORY_NAMES
15
13
  from validators.config.directory_exemption_constants import (
@@ -19,6 +17,7 @@ from validators.config.directory_exemption_constants import (
19
17
  all_directory_segments_in_path_pattern,
20
18
  directory_segment_names_from_path_patterns,
21
19
  is_filename_like_path_segment,
20
+ is_pytest_scratch_directory_segment,
22
21
  substring_patterns_from_path_patterns,
23
22
  )
24
23
  from validators.exempt_paths import (
@@ -36,6 +35,9 @@ ALL_SYNTHETIC_DIRECTORY_PATTERNS = frozenset(
36
35
  {"/foo/", "\\bar\\", "/baz.py", "plain", "/.hidden/qux/"}
37
36
  )
38
37
  ALL_EXPECTED_SYNTHETIC_SEGMENTS = frozenset({"foo", "bar", ".hidden", "qux"})
38
+ OVER_LONG_STEM_DIRECTORY_NAME = "test_helpers_for_a_very_long_domain_name0"
39
+ PROJECT_ROOTED_BASETEMP_PATH = "/srv/projects/scratch-basetemp"
40
+ PYTEST_SCRATCH_DIRECTORY_NAME = "test_edit_introducing_new_viol0"
39
41
 
40
42
 
41
43
  def test_is_filename_like_path_segment_detects_extensions() -> None:
@@ -45,6 +47,21 @@ def test_is_filename_like_path_segment_detects_extensions() -> None:
45
47
  assert is_filename_like_path_segment(".claude") is False
46
48
 
47
49
 
50
+ def test_is_pytest_scratch_directory_segment_detects_numbered_test_directories() -> None:
51
+ assert is_pytest_scratch_directory_segment("test_drops_leading_anchor0") is True
52
+ assert is_pytest_scratch_directory_segment("test_edit_introducing_new_viol0") is True
53
+ assert is_pytest_scratch_directory_segment("pytest-of-root") is True
54
+ assert is_pytest_scratch_directory_segment("pytest-9") is True
55
+
56
+
57
+ def test_is_pytest_scratch_directory_segment_keeps_real_project_directories() -> None:
58
+ assert is_pytest_scratch_directory_segment("test_helpers") is False
59
+ assert is_pytest_scratch_directory_segment("tests") is False
60
+ assert is_pytest_scratch_directory_segment("scripts") is False
61
+ assert is_pytest_scratch_directory_segment("conftest") is False
62
+ assert is_pytest_scratch_directory_segment(OVER_LONG_STEM_DIRECTORY_NAME) is False
63
+
64
+
48
65
  def test_all_directory_segments_in_path_pattern_keeps_directory_tokens_only() -> None:
49
66
  assert all_directory_segments_in_path_pattern("/.claude/hooks/") == [
50
67
  ".claude",
@@ -136,32 +153,21 @@ def test_absolute_system_temp_pytest_shaped_path_stages_flat_basename(
136
153
  assert staged_path == staging_root / "legacy_module.py"
137
154
 
138
155
 
139
- def test_runner_temp_pytest_shaped_path_stages_flat_basename_when_gettempdir_differs(
156
+ def test_pytest_shaped_path_stages_flat_basename_under_every_basetemp_placement(
140
157
  tmp_path: Path,
141
- monkeypatch: pytest.MonkeyPatch,
142
158
  ) -> None:
143
- """GHA basetemp lives under RUNNER_TEMP while gettempdir is often /tmp."""
144
- runner_temp_root = tmp_path / "runner_temp"
145
- os_gettemp = tmp_path / "os_gettemp"
146
- runner_temp_root.mkdir()
147
- os_gettemp.mkdir()
148
- monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
149
- monkeypatch.setattr(
150
- "validators.system_temporary_roots.tempfile.gettempdir",
151
- lambda: str(os_gettemp),
152
- )
153
- pytest_shaped_target = (
154
- runner_temp_root
155
- / "pytest-basetemp-pkg"
156
- / "test_edit_introducing_new_viol0"
157
- / "legacy_module.py"
158
- )
159
+ """A ``--basetemp`` outside every temp root must not change the staged path."""
159
160
  staging_root = tmp_path / "staging_root"
160
161
  staging_root.mkdir()
161
- staged_path = _temporary_path_preserving_directory_signal(
162
- staging_root, str(pytest_shaped_target)
163
- )
164
- assert staged_path == staging_root / "legacy_module.py"
162
+ all_basetemp_roots = (tmp_path, Path(PROJECT_ROOTED_BASETEMP_PATH))
163
+ all_staged_paths = {
164
+ _temporary_path_preserving_directory_signal(
165
+ staging_root,
166
+ str(each_root / PYTEST_SCRATCH_DIRECTORY_NAME / "legacy_module.py"),
167
+ )
168
+ for each_root in all_basetemp_roots
169
+ }
170
+ assert all_staged_paths == {staging_root / "legacy_module.py"}
165
171
 
166
172
 
167
173
  def test_substring_patterns_from_path_patterns_skips_directory_tokens() -> None:
@@ -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.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.name != "Mypy" for each_outcome in all_outcomes)
60
+ assert all(each_outcome.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.name == "Magic Values"
102
+ )
103
+ assert magic_values_outcome.output == "\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.name == "TODO Tracking")
115
+ assert todo_outcome.output == "\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.name == "Abbreviations"
129
+ )
130
+ assert abbreviations_outcome.passed 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.name == "React")
143
+ assert react_outcome.output == "\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.name == "Test Safety")
159
+ assert test_safety_outcome.output == "\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.name == "Abbreviations"
170
+ )
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)
@@ -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)