claude-dev-env 1.44.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +625 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +597 -12
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""Tests for open_questions_in_plans_blocker hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
HOOK_SCRIPT_PATH = os.path.join(
|
|
10
|
+
os.path.dirname(__file__), "open_questions_in_plans_blocker.py"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_plan_with_open_questions = (
|
|
14
|
+
"## Context\nA plan.\n\n## Open Questions\n- Which auth provider?\n"
|
|
15
|
+
)
|
|
16
|
+
_plan_without_open_questions = "## Context\nA plan.\n\n## Approach\nDo the thing.\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _RunHook:
|
|
20
|
+
def __call__(
|
|
21
|
+
self, tool_name: str, tool_input: dict
|
|
22
|
+
) -> subprocess.CompletedProcess:
|
|
23
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
26
|
+
input=payload,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_run_hook = _RunHook()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_blocks_write_plan_with_open_questions_heading():
|
|
37
|
+
result = _run_hook(
|
|
38
|
+
"Write",
|
|
39
|
+
{
|
|
40
|
+
"file_path": os.path.expanduser("~/.claude/plans/add-feature.md"),
|
|
41
|
+
"content": _plan_with_open_questions,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
assert result.returncode == 0
|
|
45
|
+
output = json.loads(result.stdout)
|
|
46
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
47
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_blocks_edit_plan_adding_open_questions():
|
|
51
|
+
result = _run_hook(
|
|
52
|
+
"Edit",
|
|
53
|
+
{
|
|
54
|
+
"file_path": os.path.expanduser("~/.claude/plans/refactor.md"),
|
|
55
|
+
"old_string": "## Approach\nDo it.",
|
|
56
|
+
"new_string": "## Approach\nDo it.\n\n## Open Questions\n- Which DB?",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
assert result.returncode == 0
|
|
60
|
+
output = json.loads(result.stdout)
|
|
61
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_blocks_project_local_plans_directory():
|
|
65
|
+
"""Project-local `.claude/plans/` paths are also covered."""
|
|
66
|
+
result = _run_hook(
|
|
67
|
+
"Write",
|
|
68
|
+
{
|
|
69
|
+
"file_path": ".claude/plans/my-plan.md",
|
|
70
|
+
"content": _plan_with_open_questions,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
assert result.returncode == 0
|
|
74
|
+
output = json.loads(result.stdout)
|
|
75
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_blocks_case_insensitive_heading():
|
|
79
|
+
result = _run_hook(
|
|
80
|
+
"Write",
|
|
81
|
+
{
|
|
82
|
+
"file_path": ".claude/plans/x.md",
|
|
83
|
+
"content": "# open questions\n- foo\n",
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
assert result.returncode == 0
|
|
87
|
+
output = json.loads(result.stdout)
|
|
88
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_blocks_bold_open_questions_heading():
|
|
92
|
+
result = _run_hook(
|
|
93
|
+
"Write",
|
|
94
|
+
{
|
|
95
|
+
"file_path": ".claude/plans/x.md",
|
|
96
|
+
"content": "**Open Questions**\n- foo\n",
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
assert result.returncode == 0
|
|
100
|
+
output = json.loads(result.stdout)
|
|
101
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_blocks_underscore_bold_open_questions_heading():
|
|
105
|
+
"""Canonical markdown underscore-bold `__Open Questions__` must block.
|
|
106
|
+
|
|
107
|
+
Regression for the `\b` bug between word characters `s` and `_`.
|
|
108
|
+
"""
|
|
109
|
+
result = _run_hook(
|
|
110
|
+
"Write",
|
|
111
|
+
{
|
|
112
|
+
"file_path": ".claude/plans/x.md",
|
|
113
|
+
"content": "__Open Questions__\n- foo\n",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
assert result.returncode == 0
|
|
117
|
+
output = json.loads(result.stdout)
|
|
118
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_passes_open_questions_inside_fenced_code_block():
|
|
122
|
+
"""A heading quoted inside a fenced code block (e.g., rule docs showing what NOT to write) must NOT block."""
|
|
123
|
+
content = (
|
|
124
|
+
"## Context\n"
|
|
125
|
+
"Example of what plans should NOT contain:\n\n"
|
|
126
|
+
"```markdown\n"
|
|
127
|
+
"## Open Questions\n"
|
|
128
|
+
"- placeholder\n"
|
|
129
|
+
"```\n\n"
|
|
130
|
+
"## Approach\nDo the thing.\n"
|
|
131
|
+
)
|
|
132
|
+
result = _run_hook(
|
|
133
|
+
"Write",
|
|
134
|
+
{"file_path": ".claude/plans/x.md", "content": content},
|
|
135
|
+
)
|
|
136
|
+
assert result.returncode == 0
|
|
137
|
+
assert result.stdout == ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_passes_open_questions_inside_inline_code():
|
|
141
|
+
"""A heading-shaped string inside inline code (`## Open Questions`) must NOT block."""
|
|
142
|
+
result = _run_hook(
|
|
143
|
+
"Write",
|
|
144
|
+
{
|
|
145
|
+
"file_path": ".claude/plans/x.md",
|
|
146
|
+
"content": "Avoid sections like `## Open Questions` in plans.\n",
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
assert result.returncode == 0
|
|
150
|
+
assert result.stdout == ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_blocks_open_questions_when_stray_backtick_precedes_real_heading():
|
|
154
|
+
"""A stray unmatched backtick on an earlier line must not cause the inline-code
|
|
155
|
+
stripper to swallow the real `## Open Questions` heading further down. CommonMark
|
|
156
|
+
inline-code spans cannot cross newlines, so the heading still has to block.
|
|
157
|
+
|
|
158
|
+
Regression for `[^`]+` greedily matching across newlines and erasing the heading.
|
|
159
|
+
"""
|
|
160
|
+
content_with_stray_backtick = (
|
|
161
|
+
"Some text with stray backtick `here.\n"
|
|
162
|
+
"\n"
|
|
163
|
+
"## Open Questions\n"
|
|
164
|
+
"- foo\n"
|
|
165
|
+
"\n"
|
|
166
|
+
"More `code` later.\n"
|
|
167
|
+
)
|
|
168
|
+
result = _run_hook(
|
|
169
|
+
"Write",
|
|
170
|
+
{"file_path": ".claude/plans/x.md", "content": content_with_stray_backtick},
|
|
171
|
+
)
|
|
172
|
+
assert result.returncode == 0
|
|
173
|
+
output = json.loads(result.stdout)
|
|
174
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
175
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_blocks_multiedit_with_open_questions_in_any_edit():
|
|
179
|
+
result = _run_hook(
|
|
180
|
+
"MultiEdit",
|
|
181
|
+
{
|
|
182
|
+
"file_path": ".claude/plans/x.md",
|
|
183
|
+
"edits": [
|
|
184
|
+
{"old_string": "foo", "new_string": "bar"},
|
|
185
|
+
{"old_string": "baz", "new_string": "## Open Questions\n- new"},
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
assert result.returncode == 0
|
|
190
|
+
output = json.loads(result.stdout)
|
|
191
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_passes_multiedit_without_open_questions():
|
|
195
|
+
result = _run_hook(
|
|
196
|
+
"MultiEdit",
|
|
197
|
+
{
|
|
198
|
+
"file_path": ".claude/plans/x.md",
|
|
199
|
+
"edits": [
|
|
200
|
+
{"old_string": "foo", "new_string": "bar"},
|
|
201
|
+
{"old_string": "baz", "new_string": "qux"},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
assert result.returncode == 0
|
|
206
|
+
assert result.stdout == ""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_passes_openquestions_concatenated_word():
|
|
210
|
+
"""`## OpenQuestions` (no separator) is a different word and must NOT block."""
|
|
211
|
+
result = _run_hook(
|
|
212
|
+
"Write",
|
|
213
|
+
{"file_path": ".claude/plans/x.md", "content": "## OpenQuestions\n- foo\n"},
|
|
214
|
+
)
|
|
215
|
+
assert result.returncode == 0
|
|
216
|
+
assert result.stdout == ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_passes_open_questionable_heading():
|
|
220
|
+
"""`## Open Questionable Plans` is a different word and must NOT block."""
|
|
221
|
+
result = _run_hook(
|
|
222
|
+
"Write",
|
|
223
|
+
{
|
|
224
|
+
"file_path": ".claude/plans/x.md",
|
|
225
|
+
"content": "## Open Questionable Plans\n- foo\n",
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
assert result.returncode == 0
|
|
229
|
+
assert result.stdout == ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_passes_plan_without_open_questions():
|
|
233
|
+
result = _run_hook(
|
|
234
|
+
"Write",
|
|
235
|
+
{
|
|
236
|
+
"file_path": os.path.expanduser("~/.claude/plans/clean.md"),
|
|
237
|
+
"content": _plan_without_open_questions,
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
assert result.returncode == 0
|
|
241
|
+
assert result.stdout == ""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_passes_open_questions_prose_outside_heading():
|
|
245
|
+
"""A plan that merely mentions 'open questions' in prose, not as a heading, is fine."""
|
|
246
|
+
result = _run_hook(
|
|
247
|
+
"Write",
|
|
248
|
+
{
|
|
249
|
+
"file_path": ".claude/plans/x.md",
|
|
250
|
+
"content": "## Context\nThere are no open questions left.\n",
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
assert result.returncode == 0
|
|
254
|
+
assert result.stdout == ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_passes_md_file_outside_plans_directory():
|
|
258
|
+
"""An `Open Questions` section in a non-plan .md file is not this hook's concern."""
|
|
259
|
+
result = _run_hook(
|
|
260
|
+
"Write",
|
|
261
|
+
{
|
|
262
|
+
"file_path": "docs/notes.md",
|
|
263
|
+
"content": _plan_with_open_questions,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
assert result.returncode == 0
|
|
267
|
+
assert result.stdout == ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_passes_non_markdown_file_in_plans_directory():
|
|
271
|
+
result = _run_hook(
|
|
272
|
+
"Write",
|
|
273
|
+
{
|
|
274
|
+
"file_path": ".claude/plans/notes.txt",
|
|
275
|
+
"content": _plan_with_open_questions,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
assert result.returncode == 0
|
|
279
|
+
assert result.stdout == ""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_passes_unknown_tool():
|
|
283
|
+
result = _run_hook(
|
|
284
|
+
"Grep",
|
|
285
|
+
{"pattern": "foo", "path": "."},
|
|
286
|
+
)
|
|
287
|
+
assert result.returncode == 0
|
|
288
|
+
assert result.stdout == ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_passes_empty_file_path():
|
|
292
|
+
result = _run_hook(
|
|
293
|
+
"Write",
|
|
294
|
+
{"file_path": "", "content": _plan_with_open_questions},
|
|
295
|
+
)
|
|
296
|
+
assert result.returncode == 0
|
|
297
|
+
assert result.stdout == ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_passes_json_decode_error():
|
|
301
|
+
result = subprocess.run(
|
|
302
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
303
|
+
input="not json",
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
check=False,
|
|
307
|
+
)
|
|
308
|
+
assert result.returncode == 0
|
|
309
|
+
assert result.stdout == ""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_passes_non_dict_stdin():
|
|
313
|
+
payload = json.dumps(["not", "a", "dict"])
|
|
314
|
+
result = subprocess.run(
|
|
315
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
316
|
+
input=payload,
|
|
317
|
+
capture_output=True,
|
|
318
|
+
text=True,
|
|
319
|
+
check=False,
|
|
320
|
+
)
|
|
321
|
+
assert result.returncode == 0
|
|
322
|
+
assert result.stdout == ""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_denial_carries_system_message_and_context():
|
|
326
|
+
result = _run_hook(
|
|
327
|
+
"Write",
|
|
328
|
+
{
|
|
329
|
+
"file_path": ".claude/plans/x.md",
|
|
330
|
+
"content": _plan_with_open_questions,
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
assert result.returncode == 0
|
|
334
|
+
output = json.loads(result.stdout)
|
|
335
|
+
assert output["suppressOutput"] is True
|
|
336
|
+
assert isinstance(output["systemMessage"], str) and output["systemMessage"]
|
|
337
|
+
additional_context = output["hookSpecificOutput"]["additionalContext"]
|
|
338
|
+
assert "AskUserQuestion" in additional_context
|
|
339
|
+
assert "investigate" in additional_context.lower()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_edit_without_open_questions_in_new_string_passes():
|
|
343
|
+
result = _run_hook(
|
|
344
|
+
"Edit",
|
|
345
|
+
{
|
|
346
|
+
"file_path": ".claude/plans/x.md",
|
|
347
|
+
"old_string": "## Open Questions\n- foo",
|
|
348
|
+
"new_string": "## Resolved\n- foo is bar",
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
assert result.returncode == 0
|
|
352
|
+
assert result.stdout == ""
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_blocks_windows_style_plans_path():
|
|
356
|
+
result = _run_hook(
|
|
357
|
+
"Write",
|
|
358
|
+
{
|
|
359
|
+
"file_path": ".claude\\plans\\my-plan.md",
|
|
360
|
+
"content": _plan_with_open_questions,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
assert result.returncode == 0
|
|
364
|
+
output = json.loads(result.stdout)
|
|
365
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _make_plan_file_on_disk(tmp_path, content: str):
|
|
369
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
370
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
plan_file = plans_directory / "existing-plan.md"
|
|
372
|
+
plan_file.write_text(content, encoding="utf-8")
|
|
373
|
+
return plan_file
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_blocks_edit_when_existing_file_has_open_questions_outside_edit_window(tmp_path):
|
|
377
|
+
"""An Edit that touches unrelated text must STILL block when the file on disk
|
|
378
|
+
already contains an `## Open Questions` heading that the edit does not remove."""
|
|
379
|
+
existing_content = (
|
|
380
|
+
"## Context\nA plan.\n\n## Open Questions\n- Which auth provider?\n\n"
|
|
381
|
+
"## Approach\nDo the thing.\n"
|
|
382
|
+
)
|
|
383
|
+
plan_file = _make_plan_file_on_disk(tmp_path, existing_content)
|
|
384
|
+
result = _run_hook(
|
|
385
|
+
"Edit",
|
|
386
|
+
{
|
|
387
|
+
"file_path": str(plan_file),
|
|
388
|
+
"old_string": "## Approach\nDo the thing.",
|
|
389
|
+
"new_string": "## Approach\nDo the thing better.",
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
assert result.returncode == 0
|
|
393
|
+
output = json.loads(result.stdout)
|
|
394
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
395
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_blocks_multiedit_when_existing_file_has_open_questions_untouched_by_any_edit(tmp_path):
|
|
399
|
+
"""A MultiEdit whose edits leave the existing `## Open Questions` section
|
|
400
|
+
on disk untouched must still block."""
|
|
401
|
+
existing_content = (
|
|
402
|
+
"# Plan\n\n## Open Questions\n- Which DB?\n\n## Steps\n- step one\n- step two\n"
|
|
403
|
+
)
|
|
404
|
+
plan_file = _make_plan_file_on_disk(tmp_path, existing_content)
|
|
405
|
+
result = _run_hook(
|
|
406
|
+
"MultiEdit",
|
|
407
|
+
{
|
|
408
|
+
"file_path": str(plan_file),
|
|
409
|
+
"edits": [
|
|
410
|
+
{"old_string": "step one", "new_string": "first step"},
|
|
411
|
+
{"old_string": "step two", "new_string": "second step"},
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
assert result.returncode == 0
|
|
416
|
+
output = json.loads(result.stdout)
|
|
417
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_passes_edit_that_removes_open_questions_from_existing_file(tmp_path):
|
|
421
|
+
"""An Edit that replaces the existing `## Open Questions` section with a
|
|
422
|
+
resolved section must NOT block — the post-edit content has no heading."""
|
|
423
|
+
existing_content = (
|
|
424
|
+
"## Context\nA plan.\n\n## Open Questions\n- Which auth provider?\n\n"
|
|
425
|
+
"## Approach\nDo the thing.\n"
|
|
426
|
+
)
|
|
427
|
+
plan_file = _make_plan_file_on_disk(tmp_path, existing_content)
|
|
428
|
+
result = _run_hook(
|
|
429
|
+
"Edit",
|
|
430
|
+
{
|
|
431
|
+
"file_path": str(plan_file),
|
|
432
|
+
"old_string": "## Open Questions\n- Which auth provider?",
|
|
433
|
+
"new_string": "## Auth\nUse OAuth via the existing provider.",
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
assert result.returncode == 0
|
|
437
|
+
assert result.stdout == ""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_passes_multiedit_that_removes_open_questions_from_existing_file(tmp_path):
|
|
441
|
+
"""A MultiEdit whose edits collectively remove the existing `## Open Questions`
|
|
442
|
+
section must NOT block — the post-edit content has no heading."""
|
|
443
|
+
existing_content = (
|
|
444
|
+
"# Plan\n\n## Open Questions\n- Which DB?\n\n## Steps\n- step one\n"
|
|
445
|
+
)
|
|
446
|
+
plan_file = _make_plan_file_on_disk(tmp_path, existing_content)
|
|
447
|
+
result = _run_hook(
|
|
448
|
+
"MultiEdit",
|
|
449
|
+
{
|
|
450
|
+
"file_path": str(plan_file),
|
|
451
|
+
"edits": [
|
|
452
|
+
{"old_string": "## Open Questions\n- Which DB?", "new_string": "## DB\nPostgres"},
|
|
453
|
+
{"old_string": "step one", "new_string": "first step"},
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
assert result.returncode == 0
|
|
458
|
+
assert result.stdout == ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_blocks_edit_when_existing_file_at_tilde_path_has_open_questions(tmp_path, monkeypatch):
|
|
462
|
+
"""An Edit against a tilde-prefixed `~/.claude/plans/x.md` path must expand the
|
|
463
|
+
tilde before reading the existing file, matching the expansion already done in
|
|
464
|
+
`_is_inside_plans_directory`. Without the expansion, the disk read fails and the
|
|
465
|
+
hook silently falls back to scanning `new_string` — reintroducing the bug for the
|
|
466
|
+
canonical home-directory plans path.
|
|
467
|
+
"""
|
|
468
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
469
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
470
|
+
existing_content = (
|
|
471
|
+
"## Context\nA plan.\n\n## Open Questions\n- Which auth provider?\n\n"
|
|
472
|
+
"## Approach\nDo the thing.\n"
|
|
473
|
+
)
|
|
474
|
+
_make_plan_file_on_disk(tmp_path, existing_content)
|
|
475
|
+
real_plan_file = tmp_path / ".claude" / "plans" / "existing-plan.md"
|
|
476
|
+
tilde_plan_path = "~/.claude/plans/existing-plan.md"
|
|
477
|
+
payload = json.dumps(
|
|
478
|
+
{
|
|
479
|
+
"tool_name": "Edit",
|
|
480
|
+
"tool_input": {
|
|
481
|
+
"file_path": tilde_plan_path,
|
|
482
|
+
"old_string": "## Approach\nDo the thing.",
|
|
483
|
+
"new_string": "## Approach\nDo the thing better.",
|
|
484
|
+
},
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
result = subprocess.run(
|
|
488
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
489
|
+
input=payload,
|
|
490
|
+
capture_output=True,
|
|
491
|
+
text=True,
|
|
492
|
+
check=False,
|
|
493
|
+
env={**os.environ, "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)},
|
|
494
|
+
)
|
|
495
|
+
assert result.returncode == 0
|
|
496
|
+
assert real_plan_file.exists()
|
|
497
|
+
output = json.loads(result.stdout)
|
|
498
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
499
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_blocks_edit_when_file_missing_but_new_string_has_open_questions(tmp_path):
|
|
503
|
+
"""When the target file does not exist on disk, the hook must fall back to
|
|
504
|
+
scanning `new_string` (preserves existing behavior for first-write edits)."""
|
|
505
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
506
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
missing_plan_file = plans_directory / "not-yet-saved.md"
|
|
508
|
+
result = _run_hook(
|
|
509
|
+
"Edit",
|
|
510
|
+
{
|
|
511
|
+
"file_path": str(missing_plan_file),
|
|
512
|
+
"old_string": "## Approach\nDo it.",
|
|
513
|
+
"new_string": "## Approach\nDo it.\n\n## Open Questions\n- foo",
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
assert result.returncode == 0
|
|
517
|
+
output = json.loads(result.stdout)
|
|
518
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_edit_with_empty_old_string_on_clean_existing_file_does_not_falsely_block(tmp_path):
|
|
522
|
+
"""An Edit with empty `old_string` against an existing clean plan must not
|
|
523
|
+
synthesize a phantom prepended `new_string`. Without the guard,
|
|
524
|
+
`existing_text.replace('', new_string, 1)` prepends `new_string` to the
|
|
525
|
+
existing file content, fabricating an `## Open Questions` heading that the
|
|
526
|
+
real Edit tool would never actually produce — leading to a false deny.
|
|
527
|
+
"""
|
|
528
|
+
clean_existing_content = "## Context\nA plan.\n\n## Approach\nDo the thing.\n"
|
|
529
|
+
plan_file = _make_plan_file_on_disk(tmp_path, clean_existing_content)
|
|
530
|
+
result = _run_hook(
|
|
531
|
+
"Edit",
|
|
532
|
+
{
|
|
533
|
+
"file_path": str(plan_file),
|
|
534
|
+
"old_string": "",
|
|
535
|
+
"new_string": "## Open Questions\n- placeholder",
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
assert result.returncode == 0
|
|
539
|
+
assert result.stdout == ""
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def test_edit_with_non_string_old_string_on_existing_file_does_not_falsely_block(tmp_path):
|
|
543
|
+
"""An Edit whose `old_string` is not a string (defensive against unexpected
|
|
544
|
+
payloads) must be treated as 'cannot reconstruct post-edit content' and
|
|
545
|
+
fall back to the unmodified existing content.
|
|
546
|
+
"""
|
|
547
|
+
clean_existing_content = "## Context\nA plan.\n\n## Approach\nDo the thing.\n"
|
|
548
|
+
plan_file = _make_plan_file_on_disk(tmp_path, clean_existing_content)
|
|
549
|
+
result = _run_hook(
|
|
550
|
+
"Edit",
|
|
551
|
+
{
|
|
552
|
+
"file_path": str(plan_file),
|
|
553
|
+
"old_string": None,
|
|
554
|
+
"new_string": "## Open Questions\n- placeholder",
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
assert result.returncode == 0
|
|
558
|
+
assert result.stdout == ""
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def test_edit_with_empty_old_string_still_blocks_when_existing_file_has_open_questions(tmp_path):
|
|
562
|
+
"""When `old_string` is empty and the existing file already contains an
|
|
563
|
+
`## Open Questions` heading, the unmodified existing content still triggers
|
|
564
|
+
the block — the guard returns existing content as-is, not a fabrication.
|
|
565
|
+
"""
|
|
566
|
+
existing_content = (
|
|
567
|
+
"## Context\nA plan.\n\n## Open Questions\n- Which auth provider?\n\n"
|
|
568
|
+
"## Approach\nDo the thing.\n"
|
|
569
|
+
)
|
|
570
|
+
plan_file = _make_plan_file_on_disk(tmp_path, existing_content)
|
|
571
|
+
result = _run_hook(
|
|
572
|
+
"Edit",
|
|
573
|
+
{
|
|
574
|
+
"file_path": str(plan_file),
|
|
575
|
+
"old_string": "",
|
|
576
|
+
"new_string": "## Approach\nDo the thing better.",
|
|
577
|
+
},
|
|
578
|
+
)
|
|
579
|
+
assert result.returncode == 0
|
|
580
|
+
output = json.loads(result.stdout)
|
|
581
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def test_edit_with_empty_old_string_on_missing_file_still_scans_new_string(tmp_path):
|
|
585
|
+
"""When the file is missing and `old_string` is empty, preserve the existing
|
|
586
|
+
missing-file fallback: scan `new_string` for an Open Questions heading.
|
|
587
|
+
"""
|
|
588
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
589
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
590
|
+
missing_plan_file = plans_directory / "not-yet-saved.md"
|
|
591
|
+
result = _run_hook(
|
|
592
|
+
"Edit",
|
|
593
|
+
{
|
|
594
|
+
"file_path": str(missing_plan_file),
|
|
595
|
+
"old_string": "",
|
|
596
|
+
"new_string": "## Approach\nDo it.\n\n## Open Questions\n- foo",
|
|
597
|
+
},
|
|
598
|
+
)
|
|
599
|
+
assert result.returncode == 0
|
|
600
|
+
output = json.loads(result.stdout)
|
|
601
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def test_multiedit_skips_edit_with_empty_old_string_on_existing_file(tmp_path):
|
|
605
|
+
"""A MultiEdit whose first entry has an empty `old_string` (and a dangerous
|
|
606
|
+
`new_string` containing an Open Questions heading) must skip that entry —
|
|
607
|
+
not prepend the new_string into the existing content via `replace('', X, 1)`.
|
|
608
|
+
"""
|
|
609
|
+
clean_existing_content = "## Context\nA plan.\n\nstep one\n"
|
|
610
|
+
plan_file = _make_plan_file_on_disk(tmp_path, clean_existing_content)
|
|
611
|
+
result = _run_hook(
|
|
612
|
+
"MultiEdit",
|
|
613
|
+
{
|
|
614
|
+
"file_path": str(plan_file),
|
|
615
|
+
"edits": [
|
|
616
|
+
{"old_string": "", "new_string": "## Open Questions\n- placeholder"},
|
|
617
|
+
{"old_string": "step one", "new_string": "first step"},
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
)
|
|
621
|
+
assert result.returncode == 0
|
|
622
|
+
assert result.stdout == ""
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_multiedit_with_only_invalid_edits_on_existing_file_returns_existing_content(tmp_path):
|
|
626
|
+
"""A MultiEdit whose every entry has an empty `old_string` must leave the
|
|
627
|
+
existing file content unchanged for the scan — no synthetic prepends.
|
|
628
|
+
"""
|
|
629
|
+
clean_existing_content = "## Context\nA plan.\n\n## Approach\nDo the thing.\n"
|
|
630
|
+
plan_file = _make_plan_file_on_disk(tmp_path, clean_existing_content)
|
|
631
|
+
result = _run_hook(
|
|
632
|
+
"MultiEdit",
|
|
633
|
+
{
|
|
634
|
+
"file_path": str(plan_file),
|
|
635
|
+
"edits": [
|
|
636
|
+
{"old_string": "", "new_string": "## Open Questions\n- one"},
|
|
637
|
+
{"old_string": "", "new_string": "## Open Questions\n- two"},
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
assert result.returncode == 0
|
|
642
|
+
assert result.stdout == ""
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_multiedit_with_only_invalid_edits_on_missing_file_still_scans_new_strings(tmp_path):
|
|
646
|
+
"""When the file is missing and every entry has an empty `old_string`,
|
|
647
|
+
preserve the missing-file fallback: scan the joined `new_string` values.
|
|
648
|
+
"""
|
|
649
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
650
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
651
|
+
missing_plan_file = plans_directory / "not-yet-saved.md"
|
|
652
|
+
result = _run_hook(
|
|
653
|
+
"MultiEdit",
|
|
654
|
+
{
|
|
655
|
+
"file_path": str(missing_plan_file),
|
|
656
|
+
"edits": [
|
|
657
|
+
{"old_string": "", "new_string": "## Open Questions\n- placeholder"},
|
|
658
|
+
],
|
|
659
|
+
},
|
|
660
|
+
)
|
|
661
|
+
assert result.returncode == 0
|
|
662
|
+
output = json.loads(result.stdout)
|
|
663
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def test_multiedit_missing_file_mixed_valid_invalid_includes_invalid_new_string(tmp_path):
|
|
667
|
+
"""When the file is missing and edits mix valid + invalid `old_string` entries,
|
|
668
|
+
the missing-file fallback must scan EVERY edit's `new_string`. Filtering by
|
|
669
|
+
`_is_valid_old_string` is only correct for the existing-file branch (where
|
|
670
|
+
`replace('', X, 1)` would fabricate a prepend). For the missing-file branch
|
|
671
|
+
we start from empty and concatenate candidate content — the safe behavior is
|
|
672
|
+
over-blocking: scan all new_strings.
|
|
673
|
+
"""
|
|
674
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
675
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
676
|
+
missing_plan_file = plans_directory / "not-yet-saved.md"
|
|
677
|
+
result = _run_hook(
|
|
678
|
+
"MultiEdit",
|
|
679
|
+
{
|
|
680
|
+
"file_path": str(missing_plan_file),
|
|
681
|
+
"edits": [
|
|
682
|
+
{"old_string": "", "new_string": "## Open Questions\n- foo\n"},
|
|
683
|
+
{"old_string": "preamble", "new_string": "epilogue"},
|
|
684
|
+
],
|
|
685
|
+
},
|
|
686
|
+
)
|
|
687
|
+
assert result.returncode == 0
|
|
688
|
+
output = json.loads(result.stdout)
|
|
689
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
690
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def test_edit_with_file_path_pointing_at_directory_does_not_crash(tmp_path):
|
|
694
|
+
"""When `file_path` points at a directory (not a file), `_read_plan_file_text_and_missing_flag`
|
|
695
|
+
raises `IsADirectoryError` on `Path.read_text`. The hook must catch it like the
|
|
696
|
+
other narrow read failures and fall back to scanning the edit's `new_string`."""
|
|
697
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
698
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
directory_as_file_path = plans_directory / "looks-like-file.md"
|
|
700
|
+
directory_as_file_path.mkdir()
|
|
701
|
+
result = _run_hook(
|
|
702
|
+
"Edit",
|
|
703
|
+
{
|
|
704
|
+
"file_path": str(directory_as_file_path),
|
|
705
|
+
"old_string": "preamble",
|
|
706
|
+
"new_string": "## Open Questions\n- placeholder",
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
assert result.returncode == 0
|
|
710
|
+
output = json.loads(result.stdout)
|
|
711
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
712
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def test_blocks_edit_when_existing_file_permission_denied_does_not_silently_pass(tmp_path):
|
|
716
|
+
"""When an Edit targets an existing plan file but the disk read raises
|
|
717
|
+
`PermissionError`, the hook cannot prove the on-disk content is clean. It must
|
|
718
|
+
conservatively block rather than silently falling back to the missing-file
|
|
719
|
+
new_string scan — the file exists with an unknown payload that could still
|
|
720
|
+
contain `## Open Questions`.
|
|
721
|
+
|
|
722
|
+
Simulated via a sidecar Python stub that monkeypatches `Path.read_text` to
|
|
723
|
+
raise `PermissionError`, then runs the hook in-process via the same JSON
|
|
724
|
+
contract as the subprocess `_run_hook` helper.
|
|
725
|
+
"""
|
|
726
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
727
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
728
|
+
plan_file = plans_directory / "unreadable.md"
|
|
729
|
+
plan_file.write_text("placeholder", encoding="utf-8")
|
|
730
|
+
stub_script = tmp_path / "run_with_permission_error.py"
|
|
731
|
+
stub_script.write_text(
|
|
732
|
+
"import json\n"
|
|
733
|
+
"import sys\n"
|
|
734
|
+
"from pathlib import Path\n"
|
|
735
|
+
"\n"
|
|
736
|
+
f"sys.path.insert(0, {repr(os.path.dirname(HOOK_SCRIPT_PATH))})\n"
|
|
737
|
+
"original_read_text = Path.read_text\n"
|
|
738
|
+
"def _raise_permission_error(self, *args, **kwargs):\n"
|
|
739
|
+
" raise PermissionError('simulated locked file')\n"
|
|
740
|
+
"Path.read_text = _raise_permission_error\n"
|
|
741
|
+
"\n"
|
|
742
|
+
"import open_questions_in_plans_blocker as hook_module\n"
|
|
743
|
+
"hook_module.main()\n",
|
|
744
|
+
encoding="utf-8",
|
|
745
|
+
)
|
|
746
|
+
payload = json.dumps(
|
|
747
|
+
{
|
|
748
|
+
"tool_name": "Edit",
|
|
749
|
+
"tool_input": {
|
|
750
|
+
"file_path": str(plan_file),
|
|
751
|
+
"old_string": "preamble",
|
|
752
|
+
"new_string": "## Approach\nDo the thing better.",
|
|
753
|
+
},
|
|
754
|
+
}
|
|
755
|
+
)
|
|
756
|
+
result = subprocess.run(
|
|
757
|
+
[sys.executable, str(stub_script)],
|
|
758
|
+
input=payload,
|
|
759
|
+
capture_output=True,
|
|
760
|
+
text=True,
|
|
761
|
+
check=False,
|
|
762
|
+
)
|
|
763
|
+
assert result.returncode == 0
|
|
764
|
+
output = json.loads(result.stdout)
|
|
765
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
766
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def test_blocks_edit_when_existing_file_unicode_decode_error_does_not_silently_pass(tmp_path):
|
|
770
|
+
"""When an Edit targets an existing plan file whose bytes are not valid UTF-8,
|
|
771
|
+
`Path.read_text(encoding='utf-8')` raises `UnicodeDecodeError`. The hook cannot
|
|
772
|
+
reason about binary content, so it must conservatively block rather than
|
|
773
|
+
silently falling back to the missing-file new_string scan.
|
|
774
|
+
"""
|
|
775
|
+
plans_directory = tmp_path / ".claude" / "plans"
|
|
776
|
+
plans_directory.mkdir(parents=True, exist_ok=True)
|
|
777
|
+
plan_file = plans_directory / "binary-content.md"
|
|
778
|
+
plan_file.write_bytes(b"\xff\xfe\xfd raw bytes that are not valid utf-8 \xff")
|
|
779
|
+
result = _run_hook(
|
|
780
|
+
"Edit",
|
|
781
|
+
{
|
|
782
|
+
"file_path": str(plan_file),
|
|
783
|
+
"old_string": "preamble",
|
|
784
|
+
"new_string": "## Approach\nDo the thing better.",
|
|
785
|
+
},
|
|
786
|
+
)
|
|
787
|
+
assert result.returncode == 0
|
|
788
|
+
output = json.loads(result.stdout)
|
|
789
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
790
|
+
assert "Open Questions" in output["hookSpecificOutput"]["permissionDecisionReason"]
|