claude-dev-env 2.31.0 → 2.32.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/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +2 -1
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_duplicate_body.py +280 -102
- package/hooks/blocking/code_rules_enforcer.py +32 -14
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/pr_description_writer_gate.py +18 -25
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +11 -5
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -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_same_file_inline_duplicate.py +251 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
- package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +84 -53
- package/hooks/blocking/test_tdd_enforcer.py +131 -1
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
- package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/abbreviation_checks.py +3 -3
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +42 -8
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +23 -80
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +3 -2
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +247 -148
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +16 -16
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -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/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +104 -0
- package/scripts/test_shared_tree_paths.py +173 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tests for the run report run_all_validators renders after a full run.
|
|
2
|
+
|
|
3
|
+
Timing metrics and the version header are the two pieces of that report. They
|
|
4
|
+
moved out of test_run_all_validators.py so the paired file keeps room for the
|
|
5
|
+
gate behavior it exists to pin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from .run_all_validators import (
|
|
11
|
+
add_timing,
|
|
12
|
+
build_json_output,
|
|
13
|
+
create_timing_metrics,
|
|
14
|
+
format_timing_report,
|
|
15
|
+
print_header,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestTimingMetrics:
|
|
20
|
+
def test_create_timing_metrics_empty(self) -> None:
|
|
21
|
+
metrics = create_timing_metrics({})
|
|
22
|
+
assert metrics.total_seconds == 0.0
|
|
23
|
+
assert metrics.validator_times == {}
|
|
24
|
+
|
|
25
|
+
def test_create_timing_metrics_with_data(self) -> None:
|
|
26
|
+
timings = {"Validator A": 1.5, "Validator B": 2.0}
|
|
27
|
+
metrics = create_timing_metrics(timings)
|
|
28
|
+
assert metrics.total_seconds == 3.5
|
|
29
|
+
assert metrics.validator_times == timings
|
|
30
|
+
|
|
31
|
+
def test_add_timing_returns_new_instance(self) -> None:
|
|
32
|
+
metrics1 = create_timing_metrics({})
|
|
33
|
+
metrics2 = add_timing(metrics1, "Test", 1.5)
|
|
34
|
+
|
|
35
|
+
assert metrics1.total_seconds == 0.0
|
|
36
|
+
assert metrics2.total_seconds == 1.5
|
|
37
|
+
assert "Test" not in metrics1.validator_times
|
|
38
|
+
assert metrics2.validator_times["Test"] == 1.5
|
|
39
|
+
|
|
40
|
+
def test_format_report_includes_all_timings(self) -> None:
|
|
41
|
+
metrics = create_timing_metrics({"Fast": 0.1, "Slow": 2.5})
|
|
42
|
+
report = format_timing_report(metrics)
|
|
43
|
+
|
|
44
|
+
assert "Fast" in report
|
|
45
|
+
assert "Slow" in report
|
|
46
|
+
assert "2.6" in report
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestVersionHeader:
|
|
50
|
+
def test_print_header_includes_version(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
51
|
+
print_header()
|
|
52
|
+
captured = capsys.readouterr()
|
|
53
|
+
|
|
54
|
+
assert "PRE-PUSH VALIDATOR RESULTS" in captured.out
|
|
55
|
+
assert "(v" in captured.out
|
|
56
|
+
|
|
57
|
+
def test_build_json_output_includes_version(self) -> None:
|
|
58
|
+
json_output = build_json_output(
|
|
59
|
+
results=[],
|
|
60
|
+
metrics=create_timing_metrics({}),
|
|
61
|
+
include_timing=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert "version" in json_output
|
|
65
|
+
assert "timestamp" in json_output
|
|
66
|
+
assert isinstance(json_output["version"], str)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Repository-root anchoring tests for the PreToolUse staging walk.
|
|
2
|
+
|
|
3
|
+
Staging mirrors the destination's exemption-signalling directories through a
|
|
4
|
+
temp path so the save-path checks read the same signals the real path carries.
|
|
5
|
+
The walk must read only the segments the project itself owns. A directory above
|
|
6
|
+
the project root — a home directory named ``tests``, a checkout parked under
|
|
7
|
+
``config`` — belongs to whoever laid out the disk, not to the project, and must
|
|
8
|
+
never lend a production file a test or config exemption.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from .run_all_validators import (
|
|
16
|
+
_temporary_path_preserving_directory_signal,
|
|
17
|
+
validate_proposed_file,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
21
|
+
|
|
22
|
+
GIT_MARKER_DIRECTORY_NAME = ".git"
|
|
23
|
+
MAGIC_VALUE_CHECK_NAME = "Magic Values"
|
|
24
|
+
PRODUCTION_CONTENT_WITH_MAGIC_VALUE = "def retry_budget_seconds() -> int:\n return 3600 * 7\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _project_root_under(parent_directory: Path) -> Path:
|
|
28
|
+
"""Create a git-marked project directory under *parent_directory*.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
parent_directory: Directory the project root is created inside.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The created project root, holding an empty ``.git`` marker directory.
|
|
35
|
+
"""
|
|
36
|
+
project_root = parent_directory / "project"
|
|
37
|
+
(project_root / GIT_MARKER_DIRECTORY_NAME).mkdir(parents=True)
|
|
38
|
+
return project_root
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _staged_relative_path(staging_directory: Path, destination_path: Path) -> str:
|
|
42
|
+
"""Return the posix staged path *destination_path* produces, relative to staging.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
staging_directory: Root of the ephemeral staging tree.
|
|
46
|
+
destination_path: Real destination the write or edit targets.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The staged path relative to *staging_directory*, in posix form.
|
|
50
|
+
"""
|
|
51
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
52
|
+
staging_directory, str(destination_path)
|
|
53
|
+
)
|
|
54
|
+
return staged_path.relative_to(staging_directory).as_posix()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _failed_check_names(destination_path: Path, proposed_content: str) -> list[str]:
|
|
58
|
+
"""Return the names of every save-path check that fails for a destination.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
destination_path: Real destination the write or edit targets.
|
|
62
|
+
proposed_content: Post-edit content the write would leave on disk.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The failing check names, in roster order.
|
|
66
|
+
"""
|
|
67
|
+
return [
|
|
68
|
+
each_result.name
|
|
69
|
+
for each_result in validate_proposed_file(str(destination_path), proposed_content)
|
|
70
|
+
if not each_result.passed
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_drop_tests_directory_above_the_project_root(tmp_path: Path) -> None:
|
|
75
|
+
"""A home directory named ``tests`` lends no test exemption to project code."""
|
|
76
|
+
parent_named_tests = tmp_path / "tests"
|
|
77
|
+
project_root = _project_root_under(parent_named_tests)
|
|
78
|
+
staging_directory = tmp_path / "staging"
|
|
79
|
+
staging_directory.mkdir()
|
|
80
|
+
|
|
81
|
+
staged_relative = _staged_relative_path(staging_directory, project_root / "src" / "worker.py")
|
|
82
|
+
|
|
83
|
+
assert staged_relative == "worker.py", (
|
|
84
|
+
f"a tests directory above the project must not stage into the path, got {staged_relative}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_should_drop_config_directory_above_the_project_root(tmp_path: Path) -> None:
|
|
89
|
+
"""A checkout parked under ``config`` lends no config exemption to project code."""
|
|
90
|
+
parent_named_config = tmp_path / "config"
|
|
91
|
+
project_root = _project_root_under(parent_named_config)
|
|
92
|
+
staging_directory = tmp_path / "staging"
|
|
93
|
+
staging_directory.mkdir()
|
|
94
|
+
|
|
95
|
+
staged_relative = _staged_relative_path(staging_directory, project_root / "src" / "worker.py")
|
|
96
|
+
|
|
97
|
+
assert staged_relative == "worker.py", (
|
|
98
|
+
f"a config directory above the project must not stage into the path, got {staged_relative}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_report_magic_value_for_production_file_under_a_tests_parent(
|
|
103
|
+
tmp_path: Path,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""The magic-value check grades project code under a ``tests`` parent directory."""
|
|
106
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
107
|
+
|
|
108
|
+
all_failed_names = _failed_check_names(
|
|
109
|
+
project_root / "src" / "worker.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert MAGIC_VALUE_CHECK_NAME in all_failed_names, (
|
|
113
|
+
f"production code under a tests parent must still be graded, got {all_failed_names}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_report_magic_value_for_production_file_under_a_config_parent(
|
|
118
|
+
tmp_path: Path,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""The magic-value check grades project code under a ``config`` parent directory."""
|
|
121
|
+
project_root = _project_root_under(tmp_path / "config")
|
|
122
|
+
|
|
123
|
+
all_failed_names = _failed_check_names(
|
|
124
|
+
project_root / "src" / "worker.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert MAGIC_VALUE_CHECK_NAME in all_failed_names, (
|
|
128
|
+
f"production code under a config parent must still be graded, got {all_failed_names}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_should_keep_test_helpers_directory_inside_the_project(tmp_path: Path) -> None:
|
|
133
|
+
"""A ``test_helpers`` package inside the project keeps its test exemption."""
|
|
134
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
135
|
+
staging_directory = tmp_path / "staging"
|
|
136
|
+
staging_directory.mkdir()
|
|
137
|
+
|
|
138
|
+
staged_relative = _staged_relative_path(
|
|
139
|
+
staging_directory, project_root / "pkg" / "test_helpers" / "worker.py"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert staged_relative == "test_helpers/worker.py", (
|
|
143
|
+
f"an in-project test_helpers directory must stage into the path, got {staged_relative}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_should_keep_scripts_directory_inside_the_project(tmp_path: Path) -> None:
|
|
148
|
+
"""A ``scripts`` directory inside the project keeps its CLI-marker signal."""
|
|
149
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
150
|
+
staging_directory = tmp_path / "staging"
|
|
151
|
+
staging_directory.mkdir()
|
|
152
|
+
|
|
153
|
+
staged_relative = _staged_relative_path(
|
|
154
|
+
staging_directory, project_root / "scripts" / "deploy.py"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert staged_relative == "scripts/deploy.py", (
|
|
158
|
+
f"an in-project scripts directory must stage into the path, got {staged_relative}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_should_keep_tests_directory_inside_the_project(tmp_path: Path) -> None:
|
|
163
|
+
"""A genuine ``tests`` directory inside the project keeps its test exemption."""
|
|
164
|
+
project_root = _project_root_under(tmp_path / "config")
|
|
165
|
+
staging_directory = tmp_path / "staging"
|
|
166
|
+
staging_directory.mkdir()
|
|
167
|
+
|
|
168
|
+
staged_relative = _staged_relative_path(
|
|
169
|
+
staging_directory, project_root / "tests" / "helpers" / "data.py"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert staged_relative == "tests/helpers/data.py", (
|
|
173
|
+
f"an in-project tests directory must stage into the path, got {staged_relative}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_should_keep_config_directory_inside_the_project(tmp_path: Path) -> None:
|
|
178
|
+
"""A ``config`` directory inside the project keeps its magic-value exemption."""
|
|
179
|
+
project_root = _project_root_under(tmp_path / "tests")
|
|
180
|
+
|
|
181
|
+
all_failed_names = _failed_check_names(
|
|
182
|
+
project_root / "config" / "timing.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert MAGIC_VALUE_CHECK_NAME not in all_failed_names, (
|
|
186
|
+
f"an in-project config module must stay magic-value exempt, got {all_failed_names}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_should_walk_every_segment_for_a_target_with_no_project_root(
|
|
191
|
+
tmp_path: Path,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""An install-tree target outside every project keeps the whole-path walk."""
|
|
194
|
+
install_target = tmp_path / ".claude" / "hooks" / "blocking" / "gate.py"
|
|
195
|
+
staging_directory = tmp_path / "staging"
|
|
196
|
+
staging_directory.mkdir()
|
|
197
|
+
|
|
198
|
+
staged_relative = _staged_relative_path(staging_directory, install_target)
|
|
199
|
+
|
|
200
|
+
assert staged_relative == ".claude/hooks/blocking/gate.py", (
|
|
201
|
+
f"a target with no project root must keep its exemption tail, got {staged_relative}"
|
|
202
|
+
)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""Tests for shared validator base types."""
|
|
1
|
+
"""Tests for shared validator base types and its shared read and parse."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
2
5
|
|
|
3
6
|
import pytest
|
|
4
7
|
|
|
5
|
-
from .validator_base import Violation
|
|
8
|
+
from .validator_base import Violation, source_text, syntax_tree
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class TestViolation:
|
|
@@ -24,3 +27,49 @@ class TestViolation:
|
|
|
24
27
|
violation = Violation(file="test.py", line=42, message="Test message")
|
|
25
28
|
violation_set = {violation}
|
|
26
29
|
assert violation in violation_set
|
|
30
|
+
|
|
31
|
+
def test_source_text_reads_the_file_once_for_repeated_callers(tmp_path: Path) -> None:
|
|
32
|
+
"""Two callers asking for the same unchanged file cause one read."""
|
|
33
|
+
target = tmp_path / "module.py"
|
|
34
|
+
target.write_text("value = 1\n", encoding="utf-8")
|
|
35
|
+
first = source_text(target)
|
|
36
|
+
second = source_text(target)
|
|
37
|
+
assert first == second == "value = 1\n"
|
|
38
|
+
assert source_text.__module__ == "validators.validator_base"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_source_text_reads_again_after_the_file_changes(tmp_path: Path) -> None:
|
|
42
|
+
"""An edited file is read again rather than served from the cache.
|
|
43
|
+
|
|
44
|
+
The cache key carries the modification time and the size, so a rewrite
|
|
45
|
+
between two calls reaches disk a second time.
|
|
46
|
+
"""
|
|
47
|
+
target = tmp_path / "module.py"
|
|
48
|
+
target.write_text("value = 1\n", encoding="utf-8")
|
|
49
|
+
assert source_text(target) == "value = 1\n"
|
|
50
|
+
target.write_text("value = 2\nvalue = 3\n", encoding="utf-8")
|
|
51
|
+
assert source_text(target) == "value = 2\nvalue = 3\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_source_text_raises_for_a_missing_file(tmp_path: Path) -> None:
|
|
55
|
+
"""A caller's own error handling still sees the read failure."""
|
|
56
|
+
with pytest.raises(OSError):
|
|
57
|
+
source_text(tmp_path / "absent.py")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_syntax_tree_returns_the_same_tree_for_the_same_source() -> None:
|
|
61
|
+
"""One parse serves every caller asking about the same text."""
|
|
62
|
+
source = "def handler() -> int:\n return 1\n"
|
|
63
|
+
assert syntax_tree(source) is syntax_tree(source)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_syntax_tree_parses_what_ast_parse_parses() -> None:
|
|
67
|
+
"""The shared parse agrees with a direct ast.parse of the same text."""
|
|
68
|
+
source = "value = 1\n"
|
|
69
|
+
assert ast.dump(syntax_tree(source)) == ast.dump(ast.parse(source))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_syntax_tree_raises_syntax_error_for_unparseable_source() -> None:
|
|
73
|
+
"""A caller's own SyntaxError branch still fires."""
|
|
74
|
+
with pytest.raises(SyntaxError):
|
|
75
|
+
syntax_tree("def broken(:\n")
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import List
|
|
10
10
|
|
|
11
|
-
from .validator_base import Violation
|
|
11
|
+
from .validator_base import Violation, source_text
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
TODO_PATTERN = re.compile(r"#\s*(TODO|FIXME)\b(?!.*#\d+)", re.IGNORECASE)
|
|
@@ -34,7 +34,7 @@ def check_untracked_todos(source: str, filename: str) -> List[Violation]:
|
|
|
34
34
|
def validate_file(file_path: Path) -> List[Violation]:
|
|
35
35
|
filename = str(file_path)
|
|
36
36
|
try:
|
|
37
|
-
source = file_path
|
|
37
|
+
source = source_text(file_path)
|
|
38
38
|
except Exception as error:
|
|
39
39
|
return [Violation(filename, 0, f"Error reading file: {error}")]
|
|
40
40
|
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import List
|
|
12
12
|
|
|
13
|
-
from .validator_base import Violation
|
|
13
|
+
from .validator_base import Violation, source_text, syntax_tree
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def check_missing_type_hints(tree: ast.AST, filename: str) -> List[Violation]:
|
|
@@ -66,8 +66,8 @@ def validate_file(file_path: Path) -> List[Violation]:
|
|
|
66
66
|
filename = str(file_path)
|
|
67
67
|
|
|
68
68
|
try:
|
|
69
|
-
source = file_path
|
|
70
|
-
tree =
|
|
69
|
+
source = source_text(file_path)
|
|
70
|
+
tree = syntax_tree(source)
|
|
71
71
|
except Exception as error:
|
|
72
72
|
return [Violation(filename, 0, f"Error: {error}")]
|
|
73
73
|
|
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import List
|
|
11
11
|
|
|
12
|
-
from .validator_base import Violation
|
|
12
|
+
from .validator_base import Violation, source_text, syntax_tree
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def check_useless_tests(tree: ast.AST, filename: str) -> List[Violation]:
|
|
@@ -66,8 +66,8 @@ def validate_file(file_path: Path) -> List[Violation]:
|
|
|
66
66
|
return []
|
|
67
67
|
|
|
68
68
|
try:
|
|
69
|
-
source = file_path
|
|
70
|
-
tree =
|
|
69
|
+
source = source_text(file_path)
|
|
70
|
+
tree = syntax_tree(source)
|
|
71
71
|
except Exception as error:
|
|
72
72
|
return [Violation(filename, 0, f"Error: {error}")]
|
|
73
73
|
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
"""Base classes for validators.
|
|
2
2
|
|
|
3
|
-
Provides shared dataclasses used across all validator modules
|
|
3
|
+
Provides shared dataclasses used across all validator modules, and the one
|
|
4
|
+
read and the one parse each of those modules shares for a given file.
|
|
4
5
|
"""
|
|
5
6
|
|
|
7
|
+
import ast
|
|
6
8
|
from dataclasses import dataclass
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config.validator_base_constants import SOURCE_CACHE_ENTRIES, SOURCE_ENCODING
|
|
7
13
|
|
|
8
14
|
|
|
9
15
|
@dataclass(frozen=True)
|
|
@@ -17,3 +23,69 @@ class Violation:
|
|
|
17
23
|
def __str__(self) -> str:
|
|
18
24
|
"""Format as file:line: message."""
|
|
19
25
|
return f"{self.file}:{self.line}: {self.message}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ValidatorResult:
|
|
30
|
+
"""Result from running a validator."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
checks: str
|
|
34
|
+
passed: bool
|
|
35
|
+
output: str
|
|
36
|
+
skipped: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
|
|
40
|
+
def _source_text_for_stat(
|
|
41
|
+
file_path_text: str, modified_nanoseconds: int, size_in_bytes: int
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Read one file, keyed on the stat that identifies this version of it."""
|
|
44
|
+
return Path(file_path_text).read_text(encoding=SOURCE_ENCODING)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def source_text(file_path: Path) -> str:
|
|
48
|
+
"""Return the file's text, reading it once however many checks ask.
|
|
49
|
+
|
|
50
|
+
Every check the save-path gate runs reads the same file. The gate hands
|
|
51
|
+
each of them one freshly written target, so the read is repeated work.
|
|
52
|
+
The cache key carries the modification time and size, so an edited file
|
|
53
|
+
is read again.
|
|
54
|
+
|
|
55
|
+
That key has one blind spot: a rewrite that leaves the size unchanged
|
|
56
|
+
inside a single modification-time tick is served from the cache. A hook
|
|
57
|
+
process handles one payload, so it cannot happen there; a long CLI pass
|
|
58
|
+
over many files is where a reader needs to know.
|
|
59
|
+
|
|
60
|
+
Raises whatever ``read_text`` raises, so a caller's own error handling
|
|
61
|
+
is unchanged.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
file_path: The file to read.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The file's decoded text.
|
|
68
|
+
"""
|
|
69
|
+
file_status = file_path.stat()
|
|
70
|
+
return _source_text_for_stat(
|
|
71
|
+
str(file_path), file_status.st_mtime_ns, file_status.st_size
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
|
|
76
|
+
def syntax_tree(source: str) -> ast.Module:
|
|
77
|
+
"""Return the parsed tree for source, parsing it once however many checks ask.
|
|
78
|
+
|
|
79
|
+
No check mutates the tree; each one walks it. So one parse serves all of
|
|
80
|
+
them, and the tree is shared rather than copied.
|
|
81
|
+
|
|
82
|
+
Raises ``SyntaxError`` exactly as ``ast.parse`` does, so a caller's own
|
|
83
|
+
error handling is unchanged.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
source: The file's text.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The parsed module.
|
|
90
|
+
"""
|
|
91
|
+
return ast.parse(source)
|
package/package.json
CHANGED
|
@@ -18,4 +18,4 @@ When a script file's literal body needs `$(...)`, author it with the Write tool,
|
|
|
18
18
|
|
|
19
19
|
## Enforcement
|
|
20
20
|
|
|
21
|
-
`shell_substitution_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) denies a command carrying a live substitution and returns the split-into-two-calls rewrite. Single-quoted runs are stripped before the scan, and a backtick preceded by an odd number of backslashes is escaped, so an inert mention passes.
|
|
21
|
+
`shell_substitution_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) denies a command carrying a live substitution and returns the split-into-two-calls rewrite. Single-quoted runs are stripped before the scan, and a backtick preceded by an odd number of backslashes is escaped, so an inert mention passes. A quoted heredoc body is dropped for the same reason: the opener forms that quote or escape the delimiter tell bash to expand nothing down to the terminator, so a backtick there is text the file receives. A bare delimiter expands its body and keeps its scan.
|
package/rules/testing.md
CHANGED
|
@@ -26,3 +26,7 @@ Tests exercise real behavior, real data, and production code paths. A test that
|
|
|
26
26
|
## The File-Level TDD Gate Reads Content
|
|
27
27
|
|
|
28
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.
|
|
29
|
+
|
|
30
|
+
## The Gate Reads a Recorded Failing Run First
|
|
31
|
+
|
|
32
|
+
`hooks/observability/test_failure_recorder.py` runs on every Bash call and records a single unchained pytest run that names a real test file path and reports a failing exit status. It stores the command, that exit status, and the path in the same content-hash store the gate reads. The gate consults that record before its freshness fallback, and honours it while the candidate's content still matches what failed. `content_hash_store.py`'s module docstring holds the full contract.
|
|
@@ -8,6 +8,15 @@ here rather than inline in the modules.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
# The installed tree's own shape is shared with every other reader of it.
|
|
12
|
+
from dev_env_scripts_constants.shared_tree_constants import ( # noqa: F401
|
|
13
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
14
|
+
PROCESS_TREE_DIRECTORY_NAME,
|
|
15
|
+
PROCESS_TREE_KILL_MODULE_FILENAME,
|
|
16
|
+
SCRIPTS_DIRECTORY_NAME,
|
|
17
|
+
SHARED_PACKAGE_DIRECTORY_NAME,
|
|
18
|
+
)
|
|
19
|
+
|
|
11
20
|
GROK_BINARY_NAME: str = "grok"
|
|
12
21
|
"""Executable name resolved on PATH for the headless grok tier."""
|
|
13
22
|
|
|
@@ -56,6 +65,16 @@ DEFAULT_PING_TIMEOUT_SECONDS: int = 120
|
|
|
56
65
|
CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
|
|
57
66
|
"""Per-user directory under the home directory that holds Claude config."""
|
|
58
67
|
|
|
68
|
+
"""Environment variable naming the managed Claude config root."""
|
|
69
|
+
|
|
70
|
+
"""Filename of the shared process-tree kill helper."""
|
|
71
|
+
|
|
72
|
+
"""Directory name of the shared install tree under the managed root."""
|
|
73
|
+
|
|
74
|
+
"""Directory name of the process-tree helper package under ``_shared``."""
|
|
75
|
+
|
|
76
|
+
"""Directory name holding Python helpers under a process-tree package."""
|
|
77
|
+
|
|
59
78
|
MANIFEST_FILENAME: str = ".claude-dev-env-manifest.json"
|
|
60
79
|
"""Install manifest written by ``claude-dev-env`` into the user Claude home."""
|
|
61
80
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Constants for resolving directories inside the installed shared tree.
|
|
2
|
+
|
|
3
|
+
These name the shape of the installed tree itself, not any one worker or
|
|
4
|
+
skill that reads it, so every caller of shared_tree_paths shares them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
CLAUDE_CONFIG_DIR_ENV_VAR: str = "CLAUDE_CONFIG_DIR"
|
|
10
|
+
|
|
11
|
+
SHARED_PACKAGE_DIRECTORY_NAME: str = "_shared"
|
|
12
|
+
|
|
13
|
+
SCRIPTS_DIRECTORY_NAME: str = "scripts"
|
|
14
|
+
|
|
15
|
+
PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
|
|
16
|
+
|
|
17
|
+
PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
|
|
@@ -25,24 +25,14 @@ Import ``run_headless_worker`` for the outcome object::
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
import os
|
|
28
29
|
import subprocess
|
|
29
30
|
import sys
|
|
30
31
|
import uuid
|
|
31
32
|
from dataclasses import dataclass
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
Path(__file__).resolve().parents[1] / "_shared" / "process-tree" / "scripts"
|
|
36
|
-
)
|
|
37
|
-
if str(_shared_process_tree_scripts_directory) not in sys.path:
|
|
38
|
-
sys.path.insert(0, str(_shared_process_tree_scripts_directory))
|
|
39
|
-
|
|
40
|
-
from process_tree_kill import ( # noqa: E402
|
|
41
|
-
should_start_new_session,
|
|
42
|
-
terminate_process_tree,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
35
|
+
from dev_env_scripts_constants.grok_worker_constants import (
|
|
46
36
|
AGENT_FLAG,
|
|
47
37
|
ALL_AUTH_FAILURE_SIGNATURES,
|
|
48
38
|
ALL_USAGE_LIMIT_SIGNATURES,
|
|
@@ -81,6 +71,19 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
81
71
|
UTF8_ENCODING,
|
|
82
72
|
WORKER_SPEC_TIMEOUT_KEY,
|
|
83
73
|
)
|
|
74
|
+
from shared_tree_paths import resolve_shared_process_tree_scripts_directory
|
|
75
|
+
|
|
76
|
+
_shared_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
|
|
77
|
+
__file__,
|
|
78
|
+
all_environment=os.environ,
|
|
79
|
+
)
|
|
80
|
+
if str(_shared_process_tree_scripts_directory) not in sys.path:
|
|
81
|
+
sys.path.insert(0, str(_shared_process_tree_scripts_directory))
|
|
82
|
+
|
|
83
|
+
from process_tree_kill import ( # noqa: E402
|
|
84
|
+
should_start_new_session,
|
|
85
|
+
terminate_process_tree,
|
|
86
|
+
)
|
|
84
87
|
|
|
85
88
|
runner_popen = subprocess.Popen
|
|
86
89
|
|