claude-dev-env 1.81.0 → 1.83.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 (74) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/README.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  7. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  11. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  12. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  13. package/hooks/blocking/CLAUDE.md +3 -0
  14. package/hooks/blocking/code_rules_constants_config.py +159 -1
  15. package/hooks/blocking/code_rules_docstrings.py +312 -9
  16. package/hooks/blocking/code_rules_enforcer.py +29 -0
  17. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  18. package/hooks/blocking/code_rules_naming_collection.py +141 -0
  19. package/hooks/blocking/code_rules_string_magic.py +68 -0
  20. package/hooks/blocking/conventional_pr_title_gate.py +444 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  22. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  23. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  24. package/hooks/blocking/state_description_blocker.py +96 -5
  25. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  26. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  28. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  29. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  30. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  31. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  32. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  33. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  34. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  35. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  36. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  37. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  38. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  39. package/hooks/blocking/test_state_description_blocker.py +135 -0
  40. package/hooks/hooks.json +10 -0
  41. package/hooks/hooks_constants/CLAUDE.md +4 -1
  42. package/hooks/hooks_constants/blocking_check_limits.py +43 -0
  43. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  44. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  45. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  46. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  47. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  48. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  49. package/package.json +1 -1
  50. package/rules/CLAUDE.md +4 -1
  51. package/rules/claude-md-orphan-file.md +5 -0
  52. package/rules/docstring-prose-matches-implementation.md +10 -1
  53. package/rules/env-var-table-code-drift.md +5 -0
  54. package/rules/es-exe-file-search.md +17 -0
  55. package/rules/no-historical-clutter.md +12 -1
  56. package/rules/orphan-css-class.md +5 -0
  57. package/rules/package-inventory-stale-entry.md +10 -0
  58. package/rules/paired-test-coverage.md +5 -0
  59. package/rules/plain-illustrative-docstrings.md +5 -0
  60. package/rules/verify-before-asking.md +7 -0
  61. package/rules/verify-runtime-state.md +40 -0
  62. package/rules/windows-filesystem-safe.md +8 -0
  63. package/rules/workers-done-before-complete.md +33 -0
  64. package/rules/workflow-substitution-slots.md +5 -0
  65. package/skills/CLAUDE.md +1 -1
  66. package/skills/autoconverge/SKILL.md +96 -5
  67. package/skills/autoconverge/reference/gotchas.md +5 -5
  68. package/skills/autoconverge/workflow/converge.contract.test.mjs +228 -27
  69. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +154 -21
  70. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  71. package/skills/autoconverge/workflow/converge.mjs +153 -49
  72. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
  74. package/skills/everything-search/SKILL.md +5 -0
@@ -0,0 +1,230 @@
1
+ """Unit tests for reviewer_spawn_gate PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import pathlib
7
+ import sys
8
+ from contextlib import redirect_stderr, redirect_stdout
9
+
10
+ import pytest
11
+
12
+ _HOOK_DIR = pathlib.Path(__file__).parent
13
+ if str(_HOOK_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_HOOK_DIR))
15
+
16
+ hook_spec = importlib.util.spec_from_file_location(
17
+ "reviewer_spawn_gate",
18
+ _HOOK_DIR / "reviewer_spawn_gate.py",
19
+ )
20
+ assert hook_spec is not None
21
+ assert hook_spec.loader is not None
22
+ hook_module = importlib.util.module_from_spec(hook_spec)
23
+ hook_spec.loader.exec_module(hook_module)
24
+
25
+ _matches_copilot_trigger = hook_module._matches_copilot_trigger
26
+ _matches_bugbot_trigger = hook_module._matches_bugbot_trigger
27
+ _reviewer_trigger = hook_module._reviewer_trigger
28
+
29
+ _SENTINEL_MARKER = hook_module.GATE_SENTINEL_MARKER
30
+ _AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME = hook_module.AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME
31
+
32
+ _COPILOT_TRIGGER_COMMAND = (
33
+ f"{_SENTINEL_MARKER} gh api repos/o/r/pulls/1/requested_reviewers "
34
+ f'-f "reviewers[]=copilot-pull-request-reviewer[bot]"'
35
+ )
36
+ _BUGBOT_TRIGGER_COMMAND = f'{_SENTINEL_MARKER} python post_fix_reply.py --body "bugbot run"'
37
+
38
+
39
+ def _write_fake_availability_script(tmp_path: pathlib.Path, exit_code: int) -> pathlib.Path:
40
+ fake_script_path = tmp_path / "fake_reviewer_availability.py"
41
+ fake_script_path.write_text(f"import sys\nsys.exit({exit_code})\n", encoding="utf-8")
42
+ return fake_script_path
43
+
44
+
45
+ def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
46
+ captured_stdout = io.StringIO()
47
+ captured_stderr = io.StringIO()
48
+ exit_code = 0
49
+ sys.stdin = io.StringIO(stdin_text)
50
+ try:
51
+ with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
52
+ try:
53
+ hook_module.main()
54
+ except SystemExit as exit_signal:
55
+ exit_code = exit_signal.code if isinstance(exit_signal.code, int) else 0
56
+ finally:
57
+ sys.stdin = sys.__stdin__
58
+ return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
59
+
60
+
61
+ def _run_hook(hook_input: dict) -> tuple[str, int]:
62
+ stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(json.dumps(hook_input))
63
+ return stdout_text, exit_code
64
+
65
+
66
+ def test_matches_copilot_trigger_true_for_request_reviewers_command() -> None:
67
+ assert _matches_copilot_trigger(_COPILOT_TRIGGER_COMMAND)
68
+
69
+
70
+ def test_matches_copilot_trigger_false_for_unrelated_command() -> None:
71
+ assert not _matches_copilot_trigger("gh pr list")
72
+
73
+
74
+ def test_matches_bugbot_trigger_true_for_rerun_comment_command() -> None:
75
+ assert _matches_bugbot_trigger(_BUGBOT_TRIGGER_COMMAND)
76
+
77
+
78
+ def test_matches_bugbot_trigger_false_for_unrelated_command() -> None:
79
+ assert not _matches_bugbot_trigger("python post_fix_reply.py --body 'looks good'")
80
+
81
+
82
+ def test_reviewer_trigger_returns_none_for_unrecognized_command() -> None:
83
+ assert _reviewer_trigger("gh pr list") is None
84
+
85
+
86
+ def test_main_allows_copilot_shaped_command_with_no_sentinel_marker(
87
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
88
+ ) -> None:
89
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
90
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
91
+ stdout_text, exit_code = _run_hook(
92
+ {
93
+ "tool_name": "Bash",
94
+ "tool_input": {
95
+ "command": (
96
+ "gh api repos/o/r/pulls/1/requested_reviewers "
97
+ '-f "reviewers[]=copilot-pull-request-reviewer[bot]"'
98
+ )
99
+ },
100
+ }
101
+ )
102
+ assert exit_code == 0
103
+ assert stdout_text == ""
104
+
105
+
106
+ def test_main_allows_copilot_trigger_when_available(
107
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
108
+ ) -> None:
109
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=0)
110
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
111
+ stdout_text, exit_code = _run_hook(
112
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
113
+ )
114
+ assert exit_code == 0
115
+ assert stdout_text == ""
116
+
117
+
118
+ def test_main_denies_copilot_trigger_when_down(
119
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
120
+ ) -> None:
121
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
122
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
123
+ stdout_text, exit_code = _run_hook(
124
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
125
+ )
126
+ assert exit_code == 0
127
+ response_payload = json.loads(stdout_text)
128
+ decision_block = response_payload["hookSpecificOutput"]
129
+ assert decision_block["permissionDecision"] == "deny"
130
+ assert "GitHub Copilot" in decision_block["permissionDecisionReason"]
131
+
132
+
133
+ def test_main_allows_copilot_trigger_on_unexpected_exit_code(
134
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
135
+ ) -> None:
136
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=2)
137
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
138
+ stdout_text, exit_code = _run_hook(
139
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
140
+ )
141
+ assert exit_code == 0
142
+ assert stdout_text == ""
143
+
144
+
145
+ def test_main_allows_copilot_trigger_when_availability_script_crashes(
146
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
147
+ ) -> None:
148
+ fake_script_path = tmp_path / "fake_reviewer_availability.py"
149
+ fake_script_path.write_text(
150
+ "import module_that_does_not_exist_anywhere\n", encoding="utf-8"
151
+ )
152
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
153
+ stdout_text, exit_code = _run_hook(
154
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
155
+ )
156
+ assert exit_code == 0
157
+ assert stdout_text == ""
158
+
159
+
160
+ def test_main_allows_bugbot_trigger_when_available(
161
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
162
+ ) -> None:
163
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=0)
164
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
165
+ stdout_text, exit_code = _run_hook(
166
+ {"tool_name": "Bash", "tool_input": {"command": _BUGBOT_TRIGGER_COMMAND}}
167
+ )
168
+ assert exit_code == 0
169
+ assert stdout_text == ""
170
+
171
+
172
+ def test_main_denies_bugbot_trigger_when_opted_out(
173
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
174
+ ) -> None:
175
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
176
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
177
+ stdout_text, exit_code = _run_hook(
178
+ {"tool_name": "Bash", "tool_input": {"command": _BUGBOT_TRIGGER_COMMAND}}
179
+ )
180
+ assert exit_code == 0
181
+ response_payload = json.loads(stdout_text)
182
+ decision_block = response_payload["hookSpecificOutput"]
183
+ assert decision_block["permissionDecision"] == "deny"
184
+ assert "Cursor Bugbot" in decision_block["permissionDecisionReason"]
185
+
186
+
187
+ def test_main_allows_marker_command_with_no_recognized_trigger(
188
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
189
+ ) -> None:
190
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
191
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
192
+ stdout_text, exit_code = _run_hook(
193
+ {"tool_name": "Bash", "tool_input": {"command": f"{_SENTINEL_MARKER} gh pr list"}}
194
+ )
195
+ assert exit_code == 0
196
+ assert stdout_text == ""
197
+
198
+
199
+ def test_main_allows_when_availability_script_missing(
200
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
201
+ ) -> None:
202
+ missing_script_path = tmp_path / "does_not_exist.py"
203
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(missing_script_path))
204
+ stdout_text, exit_code = _run_hook(
205
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
206
+ )
207
+ assert exit_code == 0
208
+ assert stdout_text == ""
209
+
210
+
211
+ def test_main_allows_non_bash_tool() -> None:
212
+ stdout_text, exit_code = _run_hook(
213
+ {"tool_name": "Write", "tool_input": {"content": _COPILOT_TRIGGER_COMMAND}}
214
+ )
215
+ assert exit_code == 0
216
+ assert stdout_text == ""
217
+
218
+
219
+ def test_main_with_empty_stdin_exits_silently() -> None:
220
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
221
+ assert exit_code == 0
222
+ assert stdout_text == ""
223
+ assert stderr_text == ""
224
+
225
+
226
+ def test_main_with_invalid_json_stdin_exits_silently() -> None:
227
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
228
+ assert exit_code == 0
229
+ assert stdout_text == ""
230
+ assert stderr_text == ""
@@ -0,0 +1,236 @@
1
+ """Tests for the stale_comment_reference_blocker hook."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ _BLOCKING_DIR = str(Path(__file__).resolve().parent)
10
+ _HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
11
+ if _BLOCKING_DIR not in sys.path:
12
+ sys.path.insert(0, _BLOCKING_DIR)
13
+ if _HOOKS_ROOT not in sys.path:
14
+ sys.path.insert(0, _HOOKS_ROOT)
15
+
16
+ from stale_comment_reference_blocker import ( # noqa: E402
17
+ build_deny_payload,
18
+ evaluate,
19
+ )
20
+
21
+ from hooks_constants.hook_block_logger import _HOOK_BLOCKS_LOG_RELATIVE_PATH # noqa: E402
22
+
23
+ HOOK_SCRIPT_PATH = str(Path(__file__).resolve().parent / "stale_comment_reference_blocker.py")
24
+
25
+ PATCHED_SLEEP_LINE = (
26
+ " with patch('theme_exports.core.exporter.asyncio.sleep', new_callable=AsyncMock):\n"
27
+ )
28
+ PATCHED_POLL_LINE = " with patch('theme_exports.core.exporter.wait_for_overlay_clear', new_callable=AsyncMock):\n"
29
+ KEPT_COMMENT_LINE = " # Mock asyncio\n"
30
+
31
+
32
+ def _run_hook(tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
33
+ payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
34
+ return subprocess.run(
35
+ [sys.executable, HOOK_SCRIPT_PATH],
36
+ input=payload,
37
+ capture_output=True,
38
+ text=True,
39
+ check=False,
40
+ )
41
+
42
+
43
+ def _write_module(tmp_path: Path, source_text: str) -> Path:
44
+ written_module_path = tmp_path / "sample_module.py"
45
+ written_module_path.write_text(source_text, encoding="utf-8")
46
+ return written_module_path
47
+
48
+
49
+ def test_blocks_edit_that_orphans_the_comment_above(tmp_path: Path) -> None:
50
+ """Rewriting the patched target under an unchanged comment naming it
51
+ is denied.
52
+ """
53
+ written_module_path = _write_module(
54
+ tmp_path,
55
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
56
+ )
57
+ outcome = _run_hook(
58
+ "Edit",
59
+ {
60
+ "file_path": str(written_module_path),
61
+ "old_string": PATCHED_SLEEP_LINE.rstrip("\n"),
62
+ "new_string": PATCHED_POLL_LINE.rstrip("\n"),
63
+ },
64
+ )
65
+ assert outcome.returncode == 0
66
+ decision = json.loads(outcome.stdout)
67
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
68
+ assert "asyncio" in decision["hookSpecificOutput"]["permissionDecisionReason"]
69
+
70
+
71
+ def test_blocks_edit_that_deletes_the_line_the_comment_names(tmp_path: Path) -> None:
72
+ """Deleting the patched line under an unchanged comment naming it is
73
+ denied the same as rewriting it would be.
74
+ """
75
+ written_module_path = _write_module(
76
+ tmp_path,
77
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
78
+ )
79
+ outcome = _run_hook(
80
+ "Edit",
81
+ {
82
+ "file_path": str(written_module_path),
83
+ "old_string": PATCHED_SLEEP_LINE,
84
+ "new_string": "",
85
+ },
86
+ )
87
+ assert outcome.returncode == 0
88
+ decision = json.loads(outcome.stdout)
89
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
90
+ assert "asyncio" in decision["hookSpecificOutput"]["permissionDecisionReason"]
91
+
92
+
93
+ def test_allows_edit_that_updates_the_comment_together(tmp_path: Path) -> None:
94
+ """An edit rewriting the comment and the line together passes."""
95
+ written_module_path = _write_module(
96
+ tmp_path,
97
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
98
+ )
99
+ outcome = _run_hook(
100
+ "Edit",
101
+ {
102
+ "file_path": str(written_module_path),
103
+ "old_string": KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
104
+ "new_string": " # Mock the overlay poll\n" + PATCHED_POLL_LINE,
105
+ },
106
+ )
107
+ assert outcome.returncode == 0
108
+ assert outcome.stdout == ""
109
+
110
+
111
+ def test_allows_edit_that_keeps_the_named_identifier(tmp_path: Path) -> None:
112
+ """A rewrite that still carries the identifier the comment names passes."""
113
+ written_module_path = _write_module(
114
+ tmp_path,
115
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
116
+ )
117
+ retained_line = " with patch('theme_exports.core.exporter.asyncio.sleep'):\n"
118
+ outcome = _run_hook(
119
+ "Edit",
120
+ {
121
+ "file_path": str(written_module_path),
122
+ "old_string": PATCHED_SLEEP_LINE.rstrip("\n"),
123
+ "new_string": retained_line.rstrip("\n"),
124
+ },
125
+ )
126
+ assert outcome.returncode == 0
127
+ assert outcome.stdout == ""
128
+
129
+
130
+ def test_allows_prose_comment_with_no_orphaned_identifier(tmp_path: Path) -> None:
131
+ """A prose comment whose tokens never name anything in the edited line
132
+ passes.
133
+ """
134
+ written_module_path = _write_module(
135
+ tmp_path,
136
+ "def launch_export() -> None:\n"
137
+ " # Overlay settle pause\n"
138
+ " runner.pause_for_overlay_settle()\n",
139
+ )
140
+ outcome = _run_hook(
141
+ "Edit",
142
+ {
143
+ "file_path": str(written_module_path),
144
+ "old_string": " runner.pause_for_overlay_settle()",
145
+ "new_string": " runner.confirm_editor_ready()",
146
+ },
147
+ )
148
+ assert outcome.returncode == 0
149
+ assert outcome.stdout == ""
150
+
151
+
152
+ def test_allows_non_python_file(tmp_path: Path) -> None:
153
+ """A non-Python target passes untouched."""
154
+ plain_text_target = tmp_path / "notes.txt"
155
+ plain_text_target.write_text("# Mock asyncio\nasyncio row\n", encoding="utf-8")
156
+ outcome = _run_hook(
157
+ "Edit",
158
+ {
159
+ "file_path": str(plain_text_target),
160
+ "old_string": "asyncio row",
161
+ "new_string": "other row",
162
+ },
163
+ )
164
+ assert outcome.returncode == 0
165
+ assert outcome.stdout == ""
166
+
167
+
168
+ def test_allows_write_tool() -> None:
169
+ """A non-Edit invocation passes untouched."""
170
+ outcome = _run_hook(
171
+ "Write",
172
+ {
173
+ "file_path": "src/sample_module.py",
174
+ "content": KEPT_COMMENT_LINE + PATCHED_POLL_LINE,
175
+ },
176
+ )
177
+ assert outcome.returncode == 0
178
+ assert outcome.stdout == ""
179
+
180
+
181
+ def test_allows_missing_file(tmp_path: Path) -> None:
182
+ """An Edit aimed at a path with no readable target passes."""
183
+ outcome = _run_hook(
184
+ "Edit",
185
+ {
186
+ "file_path": str(tmp_path / "absent_module.py"),
187
+ "old_string": "one",
188
+ "new_string": "two",
189
+ },
190
+ )
191
+ assert outcome.returncode == 0
192
+ assert outcome.stdout == ""
193
+
194
+
195
+ def test_allows_old_string_not_found(tmp_path: Path) -> None:
196
+ """An Edit whose old_string never occurs in the target passes."""
197
+ written_module_path = _write_module(tmp_path, "value_count = 1\n")
198
+ outcome = _run_hook(
199
+ "Edit",
200
+ {
201
+ "file_path": str(written_module_path),
202
+ "old_string": "never present",
203
+ "new_string": "still never present",
204
+ },
205
+ )
206
+ assert outcome.returncode == 0
207
+ assert outcome.stdout == ""
208
+
209
+
210
+ def test_deny_payload_logs_the_block(tmp_path: Path) -> None:
211
+ """One record lands in the block journal for each denial built through
212
+ evaluate plus build_deny_payload.
213
+ """
214
+ written_module_path = _write_module(
215
+ tmp_path,
216
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
217
+ )
218
+ deny_reason = evaluate(
219
+ {
220
+ "tool_name": "Edit",
221
+ "tool_input": {
222
+ "file_path": str(written_module_path),
223
+ "old_string": PATCHED_SLEEP_LINE.rstrip("\n"),
224
+ "new_string": PATCHED_POLL_LINE.rstrip("\n"),
225
+ },
226
+ }
227
+ )
228
+ assert deny_reason is not None
229
+ with patch.object(Path, "home", return_value=tmp_path):
230
+ deny_payload = build_deny_payload(deny_reason)
231
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
232
+ block_log_path = tmp_path / _HOOK_BLOCKS_LOG_RELATIVE_PATH
233
+ all_log_records = block_log_path.read_text(encoding="utf-8").strip().splitlines()
234
+ assert len(all_log_records) == 1
235
+ parsed_log_line = json.loads(all_log_records[0])
236
+ assert parsed_log_line["hook"] == "stale_comment_reference_blocker.py"
@@ -633,6 +633,141 @@ def test_handles_non_string_tool_name():
633
633
  assert result.stdout == ""
634
634
 
635
635
 
636
+ def test_detects_used_to_in_python_module_docstring() -> None:
637
+ """A module docstring carrying a transitional phrase counts as
638
+ documentation and is denied.
639
+ """
640
+ written_source = (
641
+ '"""Both paths used to pad with a flat sleep on every export."""\n'
642
+ "\n"
643
+ "RETRY_BUDGET = 5\n"
644
+ )
645
+ result = _run_hook(
646
+ "Write",
647
+ {
648
+ "file_path": "tests/test_exports.py",
649
+ "content": written_source,
650
+ },
651
+ )
652
+ assert result.returncode == 0
653
+ output = json.loads(result.stdout)
654
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
655
+ assert "used to" in output["hookSpecificOutput"]["permissionDecisionReason"]
656
+
657
+
658
+ def test_detects_previously_in_python_function_docstring() -> None:
659
+ """A function docstring carrying a transitional phrase is denied.
660
+ """
661
+ written_source = (
662
+ "def load_configuration() -> None:\n"
663
+ ' """Previously configured via an environment variable."""\n'
664
+ " raise ValueError\n"
665
+ )
666
+ result = _run_hook(
667
+ "Write",
668
+ {
669
+ "file_path": "src/configuration.py",
670
+ "content": written_source,
671
+ },
672
+ )
673
+ assert result.returncode == 0
674
+ output = json.loads(result.stdout)
675
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
676
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
677
+
678
+
679
+ def test_ignores_transitional_phrase_in_regular_python_string() -> None:
680
+ """A plain string literal counts as data rather than documentation and
681
+ passes.
682
+ """
683
+ result = _run_hook(
684
+ "Write",
685
+ {
686
+ "file_path": "src/messages.py",
687
+ "content": 'PADDING_NOTE = "used to pad with a flat sleep"\n',
688
+ },
689
+ )
690
+ assert result.returncode == 0
691
+ assert result.stdout == ""
692
+
693
+
694
+ def test_ignores_quoted_mention_inside_python_docstring() -> None:
695
+ """A docstring quoting a phrase as a mention in double quotes passes.
696
+ """
697
+ written_source = (
698
+ '"""Blocks docstrings carrying phrases like "used to" in their prose."""\n'
699
+ "\n"
700
+ "RETRY_BUDGET = 3\n"
701
+ )
702
+ result = _run_hook(
703
+ "Write",
704
+ {
705
+ "file_path": "src/scanner.py",
706
+ "content": written_source,
707
+ },
708
+ )
709
+ assert result.returncode == 0
710
+ assert result.stdout == ""
711
+
712
+
713
+ def test_detects_docstring_at_start_of_unparseable_edit_fragment() -> None:
714
+ """An Edit fragment opening with a docstring is scanned even when
715
+ parsing fails.
716
+ """
717
+ fragment = '"""The runner used to sleep between exports."""\n continue\n'
718
+ result = _run_hook(
719
+ "Edit",
720
+ {
721
+ "file_path": "src/exports.py",
722
+ "old_string": "placeholder",
723
+ "new_string": fragment,
724
+ },
725
+ )
726
+ assert result.returncode == 0
727
+ output = json.loads(result.stdout)
728
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
729
+ assert "used to" in output["hookSpecificOutput"]["permissionDecisionReason"]
730
+
731
+
732
+ def test_detects_docstring_after_def_header_in_unparseable_fragment() -> None:
733
+ """A triple-quoted block directly under a def header counts as a
734
+ docstring in a fragment.
735
+ """
736
+ fragment = (
737
+ " def render_summary(self) -> None:\n"
738
+ ' """No longer writes the summary sheet."""\n'
739
+ )
740
+ result = _run_hook(
741
+ "Edit",
742
+ {
743
+ "file_path": "src/reporting.py",
744
+ "old_string": "placeholder",
745
+ "new_string": fragment,
746
+ },
747
+ )
748
+ assert result.returncode == 0
749
+ output = json.loads(result.stdout)
750
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
751
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
752
+
753
+
754
+ def test_ignores_assigned_triple_quoted_string_in_unparseable_fragment() -> None:
755
+ """A triple-quoted block assigned to a name counts as data rather than
756
+ a docstring in a fragment.
757
+ """
758
+ fragment = 'FIXTURE_TEXT = """used to pad with a flat sleep"""\n continue\n'
759
+ result = _run_hook(
760
+ "Edit",
761
+ {
762
+ "file_path": "src/fixtures.py",
763
+ "old_string": "placeholder",
764
+ "new_string": fragment,
765
+ },
766
+ )
767
+ assert result.returncode == 0
768
+ assert result.stdout == ""
769
+
770
+
636
771
  def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
637
772
  """A deny routed through the dispatcher's native path logs one record.
638
773
 
package/hooks/hooks.json CHANGED
@@ -55,6 +55,16 @@
55
55
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_body_arg_blocker.py",
56
56
  "timeout": 10
57
57
  },
58
+ {
59
+ "type": "command",
60
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/conventional_pr_title_gate.py",
61
+ "timeout": 10
62
+ },
63
+ {
64
+ "type": "command",
65
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/reviewer_spawn_gate.py",
66
+ "timeout": 20
67
+ },
58
68
  {
59
69
  "type": "command",
60
70
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/block_main_commit.py",
@@ -18,6 +18,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
18
18
  | `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
19
19
  | `command_dispatch_constants.py` | Command-word regex, command-key access pattern, tokenization pattern, and anchors for the unanchored command-dispatch meta-gate |
20
20
  | `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
21
+ | `conventional_pr_title_gate_constants.py` | Bash tool name, gh executable basenames and pr create/edit subcommand tokens, title/repo flag names, semantic-title-CI workflow markers, the Conventional Commits type list and title pattern, the semantic-pull-request action `types:` input pattern, and block-message text for the conventional-PR-title gate |
21
22
  | `dead_argparse_argument_constants.py` | Patterns for detecting unused argparse arguments |
22
23
  | `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
23
24
  | `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
@@ -52,11 +53,13 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
52
53
  | `precommit_code_rules_gate_constants.py` | Scope argument and exit-code constants for the precommit gate |
53
54
  | `project_paths_reader.py` | Loads `~/.claude/project-paths.json` — the per-user project-path registry |
54
55
  | `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
56
+ | `reviewer_spawn_gate_constants.py` | Bash tool name, the sentinel marker, the Copilot and Bugbot trigger markers, the availability-script relative path and override env-var name, and the deny-message template for the reviewer-spawn gate |
55
57
  | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
56
58
  | `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
57
59
  | `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
58
60
  | `setup_project_paths_constants.py` | Encoding policy, BOM marker, and registry meta-key used across multiple hooks |
59
- | `state_description_blocker_constants.py` | The set of historical/comparative phrases the state-description blocker rejects |
61
+ | `stale_comment_reference_blocker_constants.py` | Identifier pattern, comment stopwords, and denial text for the stale-comment-reference blocker |
62
+ | `state_description_blocker_constants.py` | The set of historical/comparative phrases the state-description blocker rejects, plus the docstring-extraction and mention-span patterns for its Python docstring scan |
60
63
  | `stuttering_check_config.py` | Config for the stuttering (repeated-phrase) check |
61
64
  | `stuttering_import_binding_constants.py` | Import-binding patterns for the stuttering check |
62
65
  | `subprocess_budget_completeness_constants.py` | Required argument names for the subprocess-budget completeness check |