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.
Files changed (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. 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.read_text(encoding="utf-8")
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.read_text(encoding="utf-8")
70
- tree = ast.parse(source)
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.read_text(encoding="utf-8")
70
- tree = ast.parse(source)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- _shared_process_tree_scripts_directory = (
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