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.
- package/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +12 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +63 -16
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- 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
|
|
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
|
-
"""
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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)
|