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.
- 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 +11 -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_enforcer.py +57 -31
- 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 +263 -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 +57 -14
- 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_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_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -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 +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- 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 +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -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 +39 -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 +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -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 +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -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 +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- 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/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- 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 +2 -0
- 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/tests/test_codex_compat_materializer.py +37 -11
- 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
|
-
|
|
923
|
-
walking up from the config source path, so the staged copy is graded
|
|
924
|
-
the project config the real path sits in rather than the temp
|
|
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
|
|
930
|
-
|
|
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
|
|
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
|
|
946
|
-
[temporary_file],
|
|
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)
|
package/package.json
CHANGED
|
@@ -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
|
|
package/rules/git-workflow.md
CHANGED
|
@@ -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.
|