claude-dev-env 2.30.1 → 2.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -0,0 +1,150 @@
1
+ """Unit tests for the pr-description-writer spawn tracker PostToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ import pytest
13
+
14
+ _OBSERVABILITY_DIRECTORY = Path(__file__).resolve().parent
15
+ _HOOKS_DIRECTORY = _OBSERVABILITY_DIRECTORY.parent
16
+ for each_directory in (_OBSERVABILITY_DIRECTORY, _HOOKS_DIRECTORY):
17
+ if str(each_directory) not in sys.path:
18
+ sys.path.insert(0, str(each_directory))
19
+
20
+ _TRACKER_SPEC = importlib.util.spec_from_file_location(
21
+ "pr_description_writer_spawn_tracker",
22
+ _OBSERVABILITY_DIRECTORY / "pr_description_writer_spawn_tracker.py",
23
+ )
24
+ assert _TRACKER_SPEC is not None
25
+ assert _TRACKER_SPEC.loader is not None
26
+ tracker_module = importlib.util.module_from_spec(_TRACKER_SPEC)
27
+ _TRACKER_SPEC.loader.exec_module(tracker_module)
28
+
29
+ _GATE_CONSTANTS = importlib.import_module(
30
+ "hooks_constants.pr_description_writer_gate_constants"
31
+ )
32
+
33
+ _SESSION_ID = "session-abc123"
34
+ _OTHER_SUBAGENT_TYPE = "general-purpose"
35
+
36
+
37
+ def _spawn_payload(
38
+ subagent_type: str,
39
+ tool_name: str = "Agent",
40
+ session_id: str = _SESSION_ID,
41
+ ) -> dict[str, object]:
42
+ """Build a PostToolUse payload for one agent spawn.
43
+
44
+ Args:
45
+ subagent_type: The agent type the spawn names.
46
+ tool_name: The spawning tool name.
47
+ session_id: The session the spawn belongs to.
48
+
49
+ Returns:
50
+ The payload dictionary the tracker reads from stdin.
51
+ """
52
+ return {
53
+ "tool_name": tool_name,
54
+ "session_id": session_id,
55
+ "tool_input": {
56
+ "subagent_type": subagent_type,
57
+ "description": "Write the PR body",
58
+ "prompt": "Write a PR description from the diff.",
59
+ },
60
+ }
61
+
62
+
63
+ def _run_tracker(payload: dict[str, object], temp_directory: Path) -> None:
64
+ """Run the tracker's main against a payload with temp redirected.
65
+
66
+ Args:
67
+ payload: The PostToolUse payload to feed on stdin.
68
+ temp_directory: The directory standing in for the system temp root.
69
+ """
70
+ with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
71
+ with mock.patch.object(
72
+ _GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(temp_directory)
73
+ ):
74
+ tracker_module.main()
75
+
76
+
77
+ def should_record_marker_when_pr_description_writer_spawns(tmp_path: Path) -> None:
78
+ """A pr-description-writer spawn leaves this session's marker on disk."""
79
+ _run_tracker(_spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE), tmp_path)
80
+ assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
81
+
82
+
83
+ def should_leave_no_marker_when_another_agent_spawns(tmp_path: Path) -> None:
84
+ """A spawn of a different agent type records nothing."""
85
+ _run_tracker(_spawn_payload(_OTHER_SUBAGENT_TYPE), tmp_path)
86
+ assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
87
+
88
+
89
+ @pytest.mark.parametrize("spawn_tool_name", ["Agent", "Task"])
90
+ def should_record_marker_for_each_spawn_tool_name(spawn_tool_name: str, tmp_path: Path) -> None:
91
+ """Both spawning tool names record the marker."""
92
+ payload = _spawn_payload(
93
+ tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name=spawn_tool_name
94
+ )
95
+ _run_tracker(payload, tmp_path)
96
+ assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
97
+
98
+
99
+ def should_leave_no_marker_for_an_unrelated_tool(tmp_path: Path) -> None:
100
+ """A tool that is not a spawn records nothing, even naming the agent."""
101
+ payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name="Bash")
102
+ _run_tracker(payload, tmp_path)
103
+ assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
104
+
105
+
106
+ def should_keep_each_session_marker_separate(tmp_path: Path) -> None:
107
+ """A spawn in one session leaves another session's marker absent."""
108
+ payload = _spawn_payload(
109
+ tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, session_id="session-one"
110
+ )
111
+ _run_tracker(payload, tmp_path)
112
+ assert tracker_module.spawn_marker_path("session-one", tmp_path).exists() is True
113
+ assert tracker_module.spawn_marker_path("session-two", tmp_path).exists() is False
114
+
115
+
116
+ def should_record_twice_without_error(tmp_path: Path) -> None:
117
+ """A second spawn in one session leaves the marker in place."""
118
+ payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE)
119
+ _run_tracker(payload, tmp_path)
120
+ _run_tracker(payload, tmp_path)
121
+ assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
122
+
123
+
124
+ def should_ignore_a_malformed_payload(tmp_path: Path) -> None:
125
+ """Unparseable stdin records nothing and raises nothing."""
126
+ with mock.patch("sys.stdin", io.StringIO("this is not json")):
127
+ with mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(tmp_path)):
128
+ tracker_module.main()
129
+ assert list(tmp_path.iterdir()) == []
130
+
131
+
132
+ def should_ignore_a_payload_with_no_tool_input(tmp_path: Path) -> None:
133
+ """A spawn payload missing tool_input records nothing."""
134
+ payload: dict[str, object] = {"tool_name": "Agent", "session_id": _SESSION_ID}
135
+ _run_tracker(payload, tmp_path)
136
+ assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
137
+
138
+
139
+ @pytest.mark.parametrize("unusable_session_id", ["", "///"])
140
+ def should_record_nothing_without_a_usable_session_id(
141
+ unusable_session_id: str, tmp_path: Path
142
+ ) -> None:
143
+ """A payload naming no usable session records no marker at all."""
144
+ payload = _spawn_payload(
145
+ tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
146
+ session_id=unusable_session_id,
147
+ )
148
+ _run_tracker(payload, tmp_path)
149
+ assert tracker_module.spawn_marker_path(unusable_session_id, tmp_path) is None
150
+ assert list(tmp_path.iterdir()) == []
@@ -172,3 +172,35 @@ def test_should_exit_zero_on_malformed_stdin(
172
172
  ) -> None:
173
173
  _run_main_with_stdin("not valid json {{{")
174
174
  assert not any(redirected_temp_directory.iterdir())
175
+
176
+
177
+ def test_should_record_every_apply_patch_target_file(
178
+ redirected_temp_directory: pathlib.Path,
179
+ ) -> None:
180
+ working_directory = redirected_temp_directory / "repo"
181
+ working_directory.mkdir()
182
+ (working_directory / "updated.py").write_text("before\n", encoding="utf-8")
183
+ patch_command = (
184
+ "*** Begin Patch\n"
185
+ "*** Update File: updated.py\n"
186
+ "@@\n"
187
+ "-before\n"
188
+ "+after\n"
189
+ "*** Add File: added.py\n"
190
+ "+new\n"
191
+ "*** End Patch"
192
+ )
193
+ payload = json.dumps(
194
+ {
195
+ "session_id": _SESSION_ID,
196
+ "tool_name": "apply_patch",
197
+ "cwd": str(working_directory),
198
+ "tool_input": {"command": patch_command},
199
+ }
200
+ )
201
+
202
+ _run_main_with_stdin(payload)
203
+
204
+ recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
205
+ assert str((working_directory / "updated.py").resolve()) in recorded
206
+ assert str((working_directory / "added.py").resolve()) in recorded
@@ -9,17 +9,34 @@ import re
9
9
  import sys
10
10
  from pathlib import Path
11
11
 
12
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
13
- if _hooks_dir not in sys.path:
14
- sys.path.insert(0, _hooks_dir)
15
-
16
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
12
+ try:
13
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
14
+ if _hooks_dir not in sys.path:
15
+ sys.path.insert(0, _hooks_dir)
16
+
17
+ from hooks_constants.hook_block_logger import log_hook_block
18
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
19
+ except ImportError as import_error:
20
+ raise ImportError(
21
+ "hook_format_validator: cannot import its sibling modules; "
22
+ "ensure the hooks directory is importable."
23
+ ) from import_error
17
24
 
18
25
  SIMPLE_PATTERN = re.compile(
19
26
  r'python3?\s+~/\.claude/hooks/'
20
27
  )
21
28
 
22
29
 
30
+ def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
31
+ """Return the text a Write, Edit, or MultiEdit payload introduces."""
32
+ if tool_name == "MultiEdit":
33
+ return joined_new_strings(all_tool_input)
34
+ content = all_tool_input.get("content", "")
35
+ if not content:
36
+ content = all_tool_input.get("new_string", "")
37
+ return content
38
+
39
+
23
40
  def main() -> None:
24
41
  try:
25
42
  hook_input = json.load(sys.stdin)
@@ -36,10 +53,7 @@ def main() -> None:
36
53
  sys.exit(0)
37
54
 
38
55
  tool_name = hook_input.get("tool_name", "")
39
- content = tool_input.get("content", "")
40
- if not content:
41
- new_string = tool_input.get("new_string", "")
42
- content = new_string
56
+ content = _resolve_content(tool_name, tool_input)
43
57
 
44
58
  if tool_name == "Write" and content:
45
59
  try:
@@ -41,6 +41,64 @@ def test_simple_pattern_blocks_with_deny_payload(tmp_path: Path) -> None:
41
41
  assert hook_specific_output["permissionDecision"] == "deny"
42
42
 
43
43
 
44
+ def test_write_payload_reads_the_content_field(tmp_path: Path) -> None:
45
+ """_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
46
+ settings_path = tmp_path / ".claude" / "settings.json"
47
+ payload = {
48
+ "tool_name": "Write",
49
+ "tool_input": {
50
+ "file_path": str(settings_path),
51
+ "content": "python3 ~/.claude/hooks/blocking/my-hook.py",
52
+ },
53
+ }
54
+
55
+ hook_run = _run_hook(payload)
56
+
57
+ assert hook_run.returncode == 0
58
+ deny_payload = json.loads(hook_run.stdout)
59
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
60
+
61
+
62
+ def test_second_multi_edit_new_string_is_caught(tmp_path: Path) -> None:
63
+ """A simple-pattern hook command in the second edit of a MultiEdit is denied."""
64
+ settings_path = tmp_path / ".claude" / "settings.json"
65
+ payload = {
66
+ "tool_name": "MultiEdit",
67
+ "tool_input": {
68
+ "file_path": str(settings_path),
69
+ "edits": [
70
+ {"old_string": "a", "new_string": "b"},
71
+ {"old_string": "c", "new_string": "python3 ~/.claude/hooks/blocking/my-hook.py"},
72
+ ],
73
+ },
74
+ }
75
+
76
+ hook_run = _run_hook(payload)
77
+
78
+ assert hook_run.returncode == 0
79
+ deny_payload = json.loads(hook_run.stdout)
80
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
81
+
82
+
83
+ def test_clean_multi_edit_passes(tmp_path: Path) -> None:
84
+ settings_path = tmp_path / ".claude" / "settings.json"
85
+ payload = {
86
+ "tool_name": "MultiEdit",
87
+ "tool_input": {
88
+ "file_path": str(settings_path),
89
+ "edits": [
90
+ {"old_string": "a", "new_string": "b"},
91
+ {"old_string": "c", "new_string": "node -e \"require('run-hook-wrapper')\""},
92
+ ],
93
+ },
94
+ }
95
+
96
+ hook_run = _run_hook(payload)
97
+
98
+ assert hook_run.returncode == 0
99
+ assert hook_run.stdout.strip() == ""
100
+
101
+
44
102
  def test_block_logs_pre_tool_use_event(tmp_path: Path) -> None:
45
103
  fake_home = tmp_path / "home"
46
104
  fake_home.mkdir()
@@ -2,7 +2,7 @@
2
2
 
3
3
  AST-based Python style checks for code quality enforcement.
4
4
 
5
- The checks live in `python_style_checks.py`; the shared source-line splitting and function-discovery helpers they build on live in `python_style_helpers.py`. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count.
5
+ The checks live in `python_style_checks.py`; the shared source-line splitting, function-discovery, and statement-classification helpers they build on live in `python_style_helpers.py`. `python_style_import_bootstrap.py` recognizes the repo's sys.path bootstrap-guard idiom so the imports-at-top check does not penalize a file for following it. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count. `fast_save_validators.py` runs the twelve non-Mypy, non-Ruff checks in-process for the Write/Edit save-path gate, with no per-check subprocess.
6
6
 
7
7
  ## Checks Implemented
8
8
 
@@ -11,12 +11,10 @@ Detects:
11
11
  import ast
12
12
  import sys
13
13
  from pathlib import Path
14
- from typing import List, Set
14
+ from typing import List
15
15
 
16
- from .validator_base import Violation
17
-
18
-
19
- ALLOWED_SINGLE_LETTERS: Set[str] = frozenset({"i", "j", "k", "_"})
16
+ from .config.abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
17
+ from .validator_base import Violation, source_text, syntax_tree
20
18
 
21
19
 
22
20
  def check_single_letter_variables(tree: ast.AST, filename: str) -> List[Violation]:
@@ -24,7 +22,7 @@ def check_single_letter_variables(tree: ast.AST, filename: str) -> List[Violatio
24
22
 
25
23
  for node in ast.walk(tree):
26
24
  if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
27
- if len(node.id) == 1 and node.id not in ALLOWED_SINGLE_LETTERS:
25
+ if len(node.id) == 1 and node.id not in ALL_ALLOWED_SINGLE_LETTERS:
28
26
  violations.append(
29
27
  Violation(
30
28
  filename,
@@ -41,13 +39,13 @@ def validate_file(file_path: Path) -> List[Violation]:
41
39
  filename = str(file_path)
42
40
 
43
41
  try:
44
- source = file_path.read_text(encoding="utf-8")
42
+ source = source_text(file_path)
45
43
  except Exception as error:
46
44
  violations.append(Violation(filename, 0, f"Error reading file: {error}"))
47
45
  return violations
48
46
 
49
47
  try:
50
- tree = ast.parse(source)
48
+ tree = syntax_tree(source)
51
49
  except SyntaxError as error:
52
50
  violations.append(
53
51
  Violation(filename, error.lineno or 0, f"Syntax error: {error.msg}")
@@ -11,7 +11,7 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import List
13
13
 
14
- from .validator_base import Violation
14
+ from .validator_base import Violation, source_text, syntax_tree
15
15
 
16
16
 
17
17
  MAX_FUNCTION_LINES = 30
@@ -75,7 +75,7 @@ def check_file_length(file_path: Path) -> List[Violation]:
75
75
  filename = str(file_path)
76
76
 
77
77
  try:
78
- lines = file_path.read_text(encoding="utf-8").splitlines()
78
+ lines = source_text(file_path).splitlines()
79
79
  except Exception as error:
80
80
  return [Violation(filename, 0, f"Error reading file: {error}")]
81
81
 
@@ -98,8 +98,8 @@ def validate_file(file_path: Path) -> List[Violation]:
98
98
  violations.extend(check_file_length(file_path))
99
99
 
100
100
  try:
101
- source = file_path.read_text(encoding="utf-8")
102
- tree = ast.parse(source)
101
+ source = source_text(file_path)
102
+ tree = syntax_tree(source)
103
103
  except Exception as error:
104
104
  return violations + [Violation(filename, 0, f"Error: {error}")]
105
105
 
@@ -0,0 +1,3 @@
1
+ """Shared constants for abbreviation_checks.py's single-letter-name check."""
2
+
3
+ ALL_ALLOWED_SINGLE_LETTERS: frozenset[str] = frozenset({"i", "j", "k", "_"})
@@ -0,0 +1,12 @@
1
+ """Patterns and placeholders the baseline gate masks a violation message with.
2
+
3
+ Config files are exempt from the string-literal magic-value checks, so the
4
+ regex fragments and the placeholder text the scope-agnostic message shape needs
5
+ live here rather than inline in ``run_all_validators.py``.
6
+ """
7
+
8
+ QUALIFIED_SCOPE_NAME_SEPARATOR = "."
9
+ WORD_BOUNDARY_PATTERN = r"\b"
10
+ STANDALONE_NUMBER_PATTERN = r"(?<![A-Za-z0-9_])\d+(?![A-Za-z0-9_])"
11
+ SCOPE_NAME_PLACEHOLDER = "<name>"
12
+ SHAPE_NUMBER_PLACEHOLDER = "<number>"
@@ -7,7 +7,9 @@ siblings). Exact names come from the authoritative path-exemption pattern sets
7
7
  and CLI markers, unioned with the config-directory allowlist. Substring
8
8
  patterns come from the separator-free members of those same pattern sets so a
9
9
  directory such as ``pkg/test_helpers/`` stages with the same test exemption as
10
- the real path.
10
+ the real path. Directories pytest generates for its own scratch tree are
11
+ recognized by name and contribute no signal, whatever ``--basetemp`` they sit
12
+ under.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -27,6 +29,15 @@ from ..exempt_paths import (
27
29
  POSIX_DIRECTORY_SEPARATOR = "/"
28
30
  WINDOWS_DIRECTORY_SEPARATOR = "\\"
29
31
 
32
+ PYTEST_TEMPORARY_ROOT_NAME_PREFIX = "pytest-"
33
+ DECIMAL_DIGIT_CHARACTERS = "0123456789"
34
+ LOWERCASE_ASCII_LETTER_CHARACTERS = "abcdefghijklmnopqrstuvwxyz"
35
+ UNDERSCORE_CHARACTER = "_"
36
+ PYTEST_SANITIZED_TEST_NAME_LENGTH_LIMIT = 30
37
+ ALL_PYTEST_SANITIZED_TEST_NAME_CHARACTERS: frozenset[str] = frozenset(
38
+ LOWERCASE_ASCII_LETTER_CHARACTERS + DECIMAL_DIGIT_CHARACTERS + UNDERSCORE_CHARACTER
39
+ )
40
+
30
41
  ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
31
42
  {
32
43
  "TEMP",
@@ -68,6 +79,38 @@ def is_filename_like_path_segment(path_segment: str) -> bool:
68
79
  return "." in path_segment
69
80
 
70
81
 
82
+ def is_pytest_scratch_directory_segment(segment_lower: str) -> bool:
83
+ """Return True when *segment_lower* is a directory pytest itself created.
84
+
85
+ ::
86
+
87
+ is_pytest_scratch_directory_segment("test_drops_leading_anchor0") # True
88
+ is_pytest_scratch_directory_segment("pytest-of-root") # True
89
+ is_pytest_scratch_directory_segment("test_helpers") # False
90
+
91
+ ``TempPathFactory`` names its roots ``pytest-of-<user>`` and ``pytest-<n>``, and
92
+ each per-test directory as a length-capped test-name stem with a number appended.
93
+ Those generated names carry ``test_`` without naming a real project directory.
94
+
95
+ Args:
96
+ segment_lower: One lowercased directory component of a target path.
97
+
98
+ Returns:
99
+ True when pytest generated the segment rather than the project.
100
+ """
101
+ if segment_lower.startswith(PYTEST_TEMPORARY_ROOT_NAME_PREFIX):
102
+ return True
103
+ sanitized_test_name = segment_lower.rstrip(DECIMAL_DIGIT_CHARACTERS)
104
+ if not sanitized_test_name or sanitized_test_name == segment_lower:
105
+ return False
106
+ if len(sanitized_test_name) > PYTEST_SANITIZED_TEST_NAME_LENGTH_LIMIT:
107
+ return False
108
+ return all(
109
+ each_character in ALL_PYTEST_SANITIZED_TEST_NAME_CHARACTERS
110
+ for each_character in sanitized_test_name
111
+ )
112
+
113
+
71
114
  def all_directory_segments_in_path_pattern(path_pattern: str) -> list[str]:
72
115
  """Return lowercased directory tokens from one path-exemption pattern.
73
116
 
@@ -0,0 +1,32 @@
1
+ """File suffixes and report text the save-path validator roster reuses.
2
+
3
+ Config files are exempt from the string-literal magic-value checks, so the
4
+ per-language file suffixes and shared report strings live here rather than
5
+ inline in ``fast_save_validators.py``.
6
+ """
7
+
8
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
9
+
10
+ TYPESCRIPT_SOURCE_SUFFIX = ".ts"
11
+ REACT_TYPESCRIPT_SOURCE_SUFFIX = ".tsx"
12
+ JAVASCRIPT_SOURCE_SUFFIX = ".js"
13
+ REACT_JAVASCRIPT_SOURCE_SUFFIX = ".jsx"
14
+
15
+ ALL_REACT_COMPONENT_FILE_SUFFIXES: tuple[str, ...] = (
16
+ REACT_TYPESCRIPT_SOURCE_SUFFIX,
17
+ REACT_JAVASCRIPT_SOURCE_SUFFIX,
18
+ )
19
+ ALL_CODE_REFERENCE_FILE_SUFFIXES: tuple[str, ...] = (
20
+ PYTHON_SOURCE_SUFFIX,
21
+ TYPESCRIPT_SOURCE_SUFFIX,
22
+ REACT_TYPESCRIPT_SOURCE_SUFFIX,
23
+ JAVASCRIPT_SOURCE_SUFFIX,
24
+ REACT_JAVASCRIPT_SOURCE_SUFFIX,
25
+ )
26
+
27
+ NO_PYTHON_FILES_MESSAGE = "No Python files to check"
28
+ NO_TEST_FILES_MESSAGE = "No test files to check"
29
+ NO_REACT_FILES_MESSAGE = "No React files to check"
30
+ NO_CODE_REFERENCE_FILES_MESSAGE = "No code files to check"
31
+ NO_VIOLATIONS_FOUND_MESSAGE = "All checks passed"
32
+ VIOLATION_REPORT_LINE_SEPARATOR = "\n"
@@ -9,7 +9,9 @@ P50_FRACTION = 0.50
9
9
  P95_FRACTION = 0.95
10
10
  DEFAULT_TARGET_RELATIVE_PATH = "hooks/blocking/code_rules_shared.py"
11
11
  SUBPROCESS_TIMEOUT_SECONDS = 60.0
12
- ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset({"Write|Edit", "Write|Edit|MultiEdit"})
12
+ ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset(
13
+ {"Write|Edit", "Write|Edit|MultiEdit", "Write|Edit|MultiEdit|apply_patch"}
14
+ )
13
15
  ALL_TIMED_EVENT_NAMES = ("PreToolUse", "PostToolUse")
14
16
  RUN_ALL_VALIDATORS_LABEL = "run_all_validators"
15
17
  CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
@@ -0,0 +1,11 @@
1
+ """Tests for the shared abbreviation_checks allow-list constant."""
2
+
3
+ from .abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
4
+
5
+
6
+ def test_allowed_single_letters_covers_loop_counters_and_underscore() -> None:
7
+ assert ALL_ALLOWED_SINGLE_LETTERS == frozenset({"i", "j", "k", "_"})
8
+
9
+
10
+ def test_allowed_single_letters_is_immutable() -> None:
11
+ assert isinstance(ALL_ALLOWED_SINGLE_LETTERS, frozenset)
@@ -0,0 +1,9 @@
1
+ """Constants for the shared source reader and parser in validator_base."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # One save validates one file, so a handful of entries covers a run and keeps
6
+ # a long-lived CLI pass from holding every file it ever read.
7
+ SOURCE_CACHE_ENTRIES: int = 8
8
+
9
+ SOURCE_ENCODING: str = "utf-8"