claude-dev-env 1.37.1 → 1.38.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 (92) hide show
  1. package/CLAUDE.md +3 -0
  2. package/_shared/pr-loop/audit-contract.md +4 -3
  3. package/_shared/pr-loop/fix-protocol.md +2 -0
  4. package/_shared/pr-loop/gh-payloads.md +38 -37
  5. package/_shared/pr-loop/scripts/README.md +0 -1
  6. package/_shared/pr-loop/scripts/preflight.py +2 -1
  7. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +2 -2
  8. package/_shared/pr-loop/scripts/tests/test_preflight.py +22 -0
  9. package/_shared/pr-loop/state-schema.md +10 -10
  10. package/agents/clean-coder.md +4 -0
  11. package/agents/code-quality-agent.md +23 -85
  12. package/agents/groq-coder.md +8 -6
  13. package/hooks/blocking/__init__.py +0 -0
  14. package/hooks/blocking/hedging_language_blocker.py +2 -2
  15. package/hooks/blocking/state_description_blocker.py +243 -0
  16. package/hooks/blocking/tdd_enforcer.py +94 -0
  17. package/hooks/blocking/test_hedging_language_blocker.py +1 -1
  18. package/hooks/blocking/test_state_description_blocker.py +618 -0
  19. package/hooks/blocking/test_tdd_enforcer.py +152 -0
  20. package/hooks/config/state_description_blocker_constants.py +130 -0
  21. package/hooks/hooks.json +10 -0
  22. package/package.json +1 -1
  23. package/rules/no-historical-clutter.md +31 -10
  24. package/scripts/config/groq_bugteam_config.py +13 -5
  25. package/skills/bugteam/CONSTRAINTS.md +20 -27
  26. package/skills/bugteam/EXAMPLES.md +1 -1
  27. package/skills/bugteam/PROMPTS.md +60 -31
  28. package/skills/bugteam/SKILL.md +47 -47
  29. package/skills/bugteam/SKILL_EVALS.md +8 -8
  30. package/skills/bugteam/reference/github-pr-reviews.md +31 -31
  31. package/skills/bugteam/reference/team-setup.md +1 -1
  32. package/skills/bugteam/reference/teardown-publish-permissions.md +4 -4
  33. package/skills/copilot-review/SKILL.md +7 -14
  34. package/skills/findbugs/SKILL.md +2 -2
  35. package/skills/fixbugs/SKILL.md +1 -1
  36. package/skills/monitor-open-prs/SKILL.md +6 -6
  37. package/skills/pr-converge/SKILL.md +7 -6
  38. package/skills/pr-converge/reference/convergence-gates.md +28 -30
  39. package/skills/pr-converge/reference/examples.md +4 -4
  40. package/skills/pr-converge/reference/fix-protocol.md +6 -8
  41. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -10
  42. package/skills/pr-converge/reference/per-tick.md +18 -33
  43. package/skills/pr-converge/reference/stop-conditions.md +7 -7
  44. package/skills/pr-converge/scripts/README.md +65 -117
  45. package/skills/pr-review-responder/EXAMPLES.md +2 -2
  46. package/skills/pr-review-responder/PRINCIPLES.md +2 -8
  47. package/skills/pr-review-responder/README.md +7 -48
  48. package/skills/pr-review-responder/SKILL.md +2 -3
  49. package/skills/pr-review-responder/TESTING.md +8 -65
  50. package/skills/qbug/SKILL.md +10 -16
  51. package/_shared/pr-loop/scripts/config/gh_util_constants.py +0 -31
  52. package/_shared/pr-loop/scripts/gh_util.py +0 -193
  53. package/_shared/pr-loop/scripts/tests/test_gh_util.py +0 -257
  54. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +0 -61
  55. package/skills/pr-converge/scripts/check_pr_mergeability.py +0 -78
  56. package/skills/pr-converge/scripts/config/pr_converge_constants.py +0 -134
  57. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -152
  58. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +0 -70
  59. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +0 -57
  60. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +0 -70
  61. package/skills/pr-converge/scripts/fetch_claude_reviews.py +0 -61
  62. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +0 -70
  63. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +0 -61
  64. package/skills/pr-converge/scripts/mark_pr_ready.py +0 -54
  65. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +0 -49
  66. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +0 -33
  67. package/skills/pr-converge/scripts/reply_to_inline_comment.py +0 -84
  68. package/skills/pr-converge/scripts/request_copilot_review.py +0 -71
  69. package/skills/pr-converge/scripts/resolve_pr_head.py +0 -58
  70. package/skills/pr-converge/scripts/review_field_helpers.py +0 -43
  71. package/skills/pr-converge/scripts/reviewer_fetch_core.py +0 -153
  72. package/skills/pr-converge/scripts/reviewer_specs.py +0 -98
  73. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +0 -126
  74. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +0 -443
  75. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +0 -299
  76. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +0 -485
  77. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +0 -368
  78. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +0 -440
  79. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +0 -366
  80. package/skills/pr-converge/scripts/test_mark_pr_ready.py +0 -69
  81. package/skills/pr-converge/scripts/test_post_bugbot_run.py +0 -195
  82. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +0 -159
  83. package/skills/pr-converge/scripts/test_request_copilot_review.py +0 -101
  84. package/skills/pr-converge/scripts/test_resolve_pr_head.py +0 -79
  85. package/skills/pr-converge/scripts/test_review_field_helpers.py +0 -80
  86. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +0 -448
  87. package/skills/pr-converge/scripts/test_reviewer_specs.py +0 -107
  88. package/skills/pr-converge/scripts/test_trigger_bugbot.py +0 -139
  89. package/skills/pr-converge/scripts/test_view_pr_context.py +0 -155
  90. package/skills/pr-converge/scripts/trigger_bugbot.py +0 -77
  91. package/skills/pr-converge/scripts/view_pr_context.py +0 -78
  92. package/skills/pr-review-responder/scripts/respond_to_reviews.py +0 -376
@@ -0,0 +1,618 @@
1
+ """Tests for state_description_blocker hook."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ HOOK_SCRIPT_PATH = os.path.join(
9
+ os.path.dirname(__file__), "state_description_blocker.py"
10
+ )
11
+
12
+ CLEAN_PYTHON = "x = 1 # Uses a default timeout"
13
+ CLEAN_MD = "# Config\n\nThe API uses port 8080."
14
+ CLEAN_COMMENT = "# Configured with a 30-second timeout"
15
+
16
+ VIOLATION_INSTEAD_OF_COMMENT = "# Uses X instead of Y"
17
+ VIOLATION_PREVIOUSLY_COMMENT = "# Previously configured via Z"
18
+ VIOLATION_NOW_USES_COMMENT = "# Now uses the new API client"
19
+ VIOLATION_COMMENT_WITH_CLOSE_BLOCK = "# No longer functional — the */ route was deprecated"
20
+ VIOLATION_MD_INSTEAD = "# API\n\nUses GraphQL instead of REST."
21
+ VIOLATION_MD_PREVIOUSLY = "# Config\n\nPreviously set via env var."
22
+ VIOLATION_MD_NOW_USES = "# Auth\n\nNow uses OAuth2."
23
+
24
+
25
+ class _RunHook:
26
+ """Helper to test the hook via subprocess."""
27
+
28
+ def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
29
+ payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
30
+ return subprocess.run(
31
+ [sys.executable, HOOK_SCRIPT_PATH],
32
+ input=payload,
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ )
37
+
38
+
39
+ _run_hook = _RunHook()
40
+
41
+
42
+ def test_block_clean_python_comment_passes():
43
+ result = _run_hook(
44
+ "Write",
45
+ {
46
+ "file_path": "src/main.py",
47
+ "content": CLEAN_PYTHON,
48
+ },
49
+ )
50
+ assert result.returncode == 0
51
+ assert result.stdout == ""
52
+
53
+
54
+ def test_block_clean_markdown_passes():
55
+ result = _run_hook(
56
+ "Write",
57
+ {
58
+ "file_path": "docs/README.md",
59
+ "content": CLEAN_MD,
60
+ },
61
+ )
62
+ assert result.returncode == 0
63
+ assert result.stdout == ""
64
+
65
+
66
+ def test_block_clean_comment_passes():
67
+ result = _run_hook(
68
+ "Write",
69
+ {
70
+ "file_path": "src/config.py",
71
+ "content": CLEAN_COMMENT,
72
+ },
73
+ )
74
+ assert result.returncode == 0
75
+ assert result.stdout == ""
76
+
77
+
78
+ def test_block_irrelevant_file_passes():
79
+ result = _run_hook(
80
+ "Write",
81
+ {
82
+ "file_path": "data.txt",
83
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
84
+ },
85
+ )
86
+ assert result.returncode == 0
87
+ assert result.stdout == ""
88
+
89
+
90
+ def test_block_empty_content_passes():
91
+ result = _run_hook(
92
+ "Write",
93
+ {
94
+ "file_path": "src/main.py",
95
+ "content": "",
96
+ },
97
+ )
98
+ assert result.returncode == 0
99
+ assert result.stdout == ""
100
+
101
+
102
+ def test_block_unknown_tool_passes():
103
+ result = _run_hook(
104
+ "Grep",
105
+ {
106
+ "pattern": "foo",
107
+ "path": ".",
108
+ },
109
+ )
110
+ assert result.returncode == 0
111
+ assert result.stdout == ""
112
+
113
+
114
+ def test_detects_instead_of_in_comment():
115
+ result = _run_hook(
116
+ "Write",
117
+ {
118
+ "file_path": "src/main.py",
119
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
120
+ },
121
+ )
122
+ assert result.returncode == 0
123
+ output = json.loads(result.stdout)
124
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
125
+ assert "instead of" in output["hookSpecificOutput"]["permissionDecisionReason"]
126
+
127
+
128
+ def test_detects_previously_in_comment():
129
+ result = _run_hook(
130
+ "Write",
131
+ {
132
+ "file_path": "src/config.py",
133
+ "content": VIOLATION_PREVIOUSLY_COMMENT,
134
+ },
135
+ )
136
+ assert result.returncode == 0
137
+ output = json.loads(result.stdout)
138
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
139
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
140
+
141
+
142
+ def test_detects_now_uses_in_comment():
143
+ result = _run_hook(
144
+ "Write",
145
+ {
146
+ "file_path": "src/client.py",
147
+ "content": VIOLATION_NOW_USES_COMMENT,
148
+ },
149
+ )
150
+ assert result.returncode == 0
151
+ output = json.loads(result.stdout)
152
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
153
+ assert "now uses" in output["hookSpecificOutput"]["permissionDecisionReason"]
154
+
155
+
156
+ def test_detects_instead_of_in_markdown():
157
+ result = _run_hook(
158
+ "Write",
159
+ {
160
+ "file_path": "docs/api.md",
161
+ "content": VIOLATION_MD_INSTEAD,
162
+ },
163
+ )
164
+ assert result.returncode == 0
165
+ output = json.loads(result.stdout)
166
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
167
+
168
+
169
+ def test_detects_previously_in_markdown():
170
+ result = _run_hook(
171
+ "Write",
172
+ {
173
+ "file_path": "docs/config.md",
174
+ "content": VIOLATION_MD_PREVIOUSLY,
175
+ },
176
+ )
177
+ assert result.returncode == 0
178
+ output = json.loads(result.stdout)
179
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
180
+
181
+
182
+ def test_detects_now_uses_in_markdown():
183
+ result = _run_hook(
184
+ "Write",
185
+ {
186
+ "file_path": "docs/auth.md",
187
+ "content": VIOLATION_MD_NOW_USES,
188
+ },
189
+ )
190
+ assert result.returncode == 0
191
+ output = json.loads(result.stdout)
192
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
193
+
194
+
195
+ def test_detects_edit_new_string():
196
+ result = _run_hook(
197
+ "Edit",
198
+ {
199
+ "file_path": "src/main.py",
200
+ "old_string": "old_comment",
201
+ "new_string": VIOLATION_PREVIOUSLY_COMMENT,
202
+ },
203
+ )
204
+ assert result.returncode == 0
205
+ output = json.loads(result.stdout)
206
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
207
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
208
+
209
+
210
+ def test_clean_edit_passes():
211
+ result = _run_hook(
212
+ "Edit",
213
+ {
214
+ "file_path": "src/main.py",
215
+ "old_string": "x = 1",
216
+ "new_string": CLEAN_PYTHON,
217
+ },
218
+ )
219
+ assert result.returncode == 0
220
+ assert result.stdout == ""
221
+
222
+
223
+ def test_system_message_and_suppress_output():
224
+ result = _run_hook(
225
+ "Write",
226
+ {
227
+ "file_path": "src/main.py",
228
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
229
+ },
230
+ )
231
+ assert result.returncode == 0
232
+ output = json.loads(result.stdout)
233
+ assert output["suppressOutput"] is True
234
+ assert isinstance(output["systemMessage"], str)
235
+ assert len(output["systemMessage"]) > 0
236
+
237
+
238
+ def test_detects_no_longer_in_comment():
239
+ result = _run_hook(
240
+ "Write",
241
+ {
242
+ "file_path": "src/config.py",
243
+ "content": "# No longer supports legacy mode",
244
+ },
245
+ )
246
+ assert result.returncode == 0
247
+ output = json.loads(result.stdout)
248
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
249
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
250
+
251
+
252
+ def test_detects_used_to_in_comment():
253
+ result = _run_hook(
254
+ "Write",
255
+ {
256
+ "file_path": "src/config.py",
257
+ "content": "# Used to be hardcoded",
258
+ },
259
+ )
260
+ assert result.returncode == 0
261
+ output = json.loads(result.stdout)
262
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
263
+ assert "used to" in output["hookSpecificOutput"]["permissionDecisionReason"]
264
+
265
+
266
+ def test_detects_switched_to_in_comment():
267
+ result = _run_hook(
268
+ "Write",
269
+ {
270
+ "file_path": "src/config.py",
271
+ "content": "# Switched to async processing",
272
+ },
273
+ )
274
+ assert result.returncode == 0
275
+ output = json.loads(result.stdout)
276
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
277
+ assert "switched to" in output["hookSpecificOutput"]["permissionDecisionReason"]
278
+
279
+
280
+ def test_detects_comment_with_close_block_token():
281
+ """A single-line # comment containing */ (e.g. docs referencing a deprecated */ route)
282
+ should still be scanned for violations — the `*/` must not trigger a spurious
283
+ block-comment `continue` that skips the single-line comment check.
284
+ Real pattern: midjourney-docs 'no longer works' + route path with */."""
285
+ result = _run_hook(
286
+ "Write",
287
+ {
288
+ "file_path": "src/config.py",
289
+ "content": VIOLATION_COMMENT_WITH_CLOSE_BLOCK,
290
+ },
291
+ )
292
+ assert result.returncode == 0
293
+ output = json.loads(result.stdout)
294
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
295
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
296
+
297
+
298
+ def test_detects_inline_trailing_comment():
299
+ """A code line with a trailing inline comment containing historical language should
300
+ be blocked. Real pattern: ARCHITECTURE.md 'no longer needed' reference in migration
301
+ context — simulates `x = val # previously needed but now handled elsewhere`."""
302
+ result = _run_hook(
303
+ "Write",
304
+ {
305
+ "file_path": "src/main.py",
306
+ "content": "max_retries = 3 # No longer needed since async retry handles it",
307
+ },
308
+ )
309
+ assert result.returncode == 0
310
+ output = json.loads(result.stdout)
311
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
312
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
313
+
314
+
315
+ def test_ignores_c_preprocessor_directive():
316
+ """A C/C++ #error directive should NOT be treated as a comment.
317
+ # is not a comment marker in C — startswith should only check //."""
318
+ result = _run_hook(
319
+ "Write",
320
+ {
321
+ "file_path": "src/config.h",
322
+ "content": '#error "This code previously used replaced API"',
323
+ },
324
+ )
325
+ assert result.returncode == 0
326
+ assert result.stdout == ""
327
+
328
+
329
+ def test_ignores_hash_in_javascript_inline():
330
+ """A JavaScript line with # in a string literal should NOT trigger inline
331
+ comment extraction — # is not a comment marker in JS. Only // should be checked.
332
+ Real pattern: `const sel = "#originally-dark"` would falsely match `originally`
333
+ if # were treated as a comment marker."""
334
+ result = _run_hook(
335
+ "Write",
336
+ {
337
+ "file_path": "src/main.js",
338
+ "content": 'const selector = "#originally-dark" // Use dark as default',
339
+ },
340
+ )
341
+ assert result.returncode == 0
342
+ assert result.stdout == ""
343
+
344
+
345
+ def test_ignores_double_slash_in_js_url():
346
+ """A JavaScript/TypeScript line with a URL containing // should NOT trigger inline
347
+ comment extraction on the URL. The :// protocol marker should be
348
+ recognized and skipped. Real pattern: `fetch("https://api.example.com/replaces")`
349
+ should not false-positive on `replaces` in the URL path."""
350
+ result = _run_hook(
351
+ "Write",
352
+ {
353
+ "file_path": "src/fetch.ts",
354
+ "content": 'fetch("https://api.example.com/replaces")',
355
+ },
356
+ )
357
+ assert result.returncode == 0
358
+ assert result.stdout == ""
359
+
360
+
361
+ def test_block_comment_continuation_with_nested_glob():
362
+ """A continuation line inside a /* */ block comment that contains /* in its
363
+ content should append the full line, not truncate from the nested /* onward.
364
+ * List: no longer supported /* pattern — violation should still be detected."""
365
+ content = "/*\n * List: no longer supported /* pattern\n */"
366
+ result = _run_hook(
367
+ "Write",
368
+ {
369
+ "file_path": "src/cache.ts",
370
+ "content": content,
371
+ },
372
+ )
373
+ assert result.returncode == 0
374
+ output = json.loads(result.stdout)
375
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
376
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
377
+
378
+
379
+ def test_detects_inline_after_url_on_same_line():
380
+ """A JS/TS line with a URL followed by a real inline comment containing a
381
+ violation should still be detected. const url = "https://api.com"; // no longer used
382
+ — the // before no longer is a real inline comment, not a URL."""
383
+ result = _run_hook(
384
+ "Write",
385
+ {
386
+ "file_path": "src/fetch.ts",
387
+ "content": 'const url = "https://api.com"; // no longer used',
388
+ },
389
+ )
390
+ assert result.returncode == 0
391
+ output = json.loads(result.stdout)
392
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
393
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
394
+
395
+
396
+ def test_ignores_code_before_block_comment():
397
+ """A line with code before /* */ should only scan the comment portion, not the code.
398
+ `cache.replaces(old); /* Use fresh cache */` should NOT trigger on `replaces`
399
+ in the code portion."""
400
+ result = _run_hook(
401
+ "Write",
402
+ {
403
+ "file_path": "src/cache.ts",
404
+ "content": "cache.replaces(old); /* Use fresh cache */",
405
+ },
406
+ )
407
+ assert result.returncode == 0
408
+ assert result.stdout == ""
409
+
410
+
411
+ def test_ignores_url_with_double_slash_in_python():
412
+ """A Python line with a URL containing // should NOT trigger inline comment
413
+ extraction — // is floor division in Python, not a comment marker.
414
+ Real pattern: `url = "https://api.example.com/replaces/v1"` would falsely
415
+ extract `//api.example.com/replaces/v1"` as a comment and match `replaces`."""
416
+ result = _run_hook(
417
+ "Write",
418
+ {
419
+ "file_path": "src/main.py",
420
+ "content": 'url = "https://api.example.com/replaces/v1"',
421
+ },
422
+ )
423
+ assert result.returncode == 0
424
+ assert result.stdout == ""
425
+
426
+
427
+ def test_ignores_instead_of_in_code_string_with_inline_comment():
428
+ """A code line containing 'instead of' inside a string literal with a trailing
429
+ comment should NOT be blocked — only the comment portion after # is scanned.
430
+ This prevents false-positives from `msg = 'instead of' # comment` patterns."""
431
+ result = _run_hook(
432
+ "Write",
433
+ {
434
+ "file_path": "src/main.py",
435
+ "content": "msg = 'instead of' # Use the default timeout",
436
+ },
437
+ )
438
+ assert result.returncode == 0
439
+ assert result.stdout == ""
440
+
441
+
442
+ def test_ignores_glob_pattern_with_star_in_python():
443
+ """A Python line with /* (glob pattern) should NOT set is_in_block_comment.
444
+ Python doesn't support /* */ block comments. `glob("src/*")` must not cause
445
+ subsequent lines to be treated as block comment content."""
446
+ content = 'files = glob("src/*")\nprint("previously done") # clean comment'
447
+ result = _run_hook(
448
+ "Write",
449
+ {
450
+ "file_path": "src/main.py",
451
+ "content": content,
452
+ },
453
+ )
454
+ assert result.returncode == 0
455
+ assert result.stdout == ""
456
+
457
+
458
+ def test_ignores_comment_with_glob_pattern():
459
+ """A single-line comment containing /* should NOT set is_in_block_comment for
460
+ subsequent lines. Without this guard, `# Uses /* glob syntax` would set block-
461
+ comment state and all subsequent code lines would be falsely treated as comments."""
462
+ content = "# Uses /* glob syntax\nnext_line = calc() # clean comment"
463
+ result = _run_hook(
464
+ "Write",
465
+ {
466
+ "file_path": "src/main.py",
467
+ "content": content,
468
+ },
469
+ )
470
+ assert result.returncode == 0
471
+ assert result.stdout == ""
472
+
473
+
474
+ def test_single_line_block_comment_closes():
475
+ """A same-line /* */ block comment like code(); /* note */ should close
476
+ on the same line. is_in_block_comment must not stay True after the line."""
477
+ content = "code(); /* previously used */\nclean_code()"
478
+ result = _run_hook(
479
+ "Write",
480
+ {
481
+ "file_path": "src/cache.ts",
482
+ "content": content,
483
+ },
484
+ )
485
+ assert result.returncode == 0
486
+ output = json.loads(result.stdout)
487
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
488
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
489
+
490
+
491
+ def test_block_comment_with_url_closes_correctly():
492
+ """A block comment line containing // in a URL should still detect */ and close
493
+ the block comment state. `* https://example.com/ */` must not trigger inline
494
+ comment extraction on // and skip the */ close check."""
495
+ content = "/*\n * https://example.com/api/v1 */\nnormal_code()"
496
+ result = _run_hook(
497
+ "Write",
498
+ {
499
+ "file_path": "src/cache.ts",
500
+ "content": content,
501
+ },
502
+ )
503
+ assert result.returncode == 0
504
+ assert result.stdout == ""
505
+
506
+
507
+ def test_same_line_block_comment_with_trailing_inline():
508
+ """A line with a same-line /* */ block comment followed by a // inline comment
509
+ containing a violation should detect the violation. The `continue` after same-line
510
+ /* */ extraction must not skip the trailing inline comment check.
511
+ `code(); /* clean */ // no longer used` — the `no longer` in // must be detected."""
512
+ content = 'code(); /* clean */ // no longer used'
513
+ result = _run_hook(
514
+ "Write",
515
+ {
516
+ "file_path": "src/fetch.ts",
517
+ "content": content,
518
+ },
519
+ )
520
+ assert result.returncode == 0
521
+ output = json.loads(result.stdout)
522
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
523
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
524
+
525
+
526
+ def test_multi_line_block_comment_close_with_trailing_inline():
527
+ """A multi-line /* */ block comment where the closing line has a // inline comment
528
+ containing a violation should detect the violation. The `continue` after block
529
+ comment close line must not skip the trailing inline comment check."""
530
+ content = "/*\n * end */ // no longer used"
531
+ result = _run_hook(
532
+ "Write",
533
+ {
534
+ "file_path": "src/cache.ts",
535
+ "content": content,
536
+ },
537
+ )
538
+ assert result.returncode == 0
539
+ output = json.loads(result.stdout)
540
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
541
+ assert "no longer" in output["hookSpecificOutput"]["permissionDecisionReason"]
542
+
543
+
544
+ def test_detects_earliest_inline_marker_in_php():
545
+ """A PHP line with a // inline comment followed by a # hash should find the //
546
+ marker (earliest) not the # marker for comment extraction.
547
+ `echo $x; // previously used #tag` — `previously` in // must be detected."""
548
+ result = _run_hook(
549
+ "Write",
550
+ {
551
+ "file_path": "src/index.php",
552
+ "content": 'echo $x; // previously used #tag',
553
+ },
554
+ )
555
+ assert result.returncode == 0
556
+ output = json.loads(result.stdout)
557
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
558
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
559
+
560
+
561
+ def test_additional_context_contains_examples():
562
+ result = _run_hook(
563
+ "Write",
564
+ {
565
+ "file_path": "src/main.py",
566
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
567
+ },
568
+ )
569
+ assert result.returncode == 0
570
+ output = json.loads(result.stdout)
571
+ ctx = output["hookSpecificOutput"].get("additionalContext", "")
572
+ assert "BAD:" in ctx
573
+ assert "GOOD:" in ctx
574
+
575
+
576
+ def test_handles_non_dict_stdin():
577
+ """A non-dict root JSON object on stdin (e.g. a JSON array) should exit
578
+ cleanly without raising — the hook must guard against malformed payloads."""
579
+ payload = json.dumps(["not", "a", "dict"])
580
+ result = subprocess.run(
581
+ [sys.executable, HOOK_SCRIPT_PATH],
582
+ input=payload,
583
+ capture_output=True,
584
+ text=True,
585
+ check=False,
586
+ )
587
+ assert result.returncode == 0
588
+ assert result.stdout == ""
589
+
590
+
591
+ def test_handles_non_dict_tool_input():
592
+ """A tool_input that is not a dict should exit cleanly — the hook must
593
+ guard against tool_input.get() on a non-dict value."""
594
+ payload = json.dumps({"tool_name": "Write", "tool_input": "not_a_dict"})
595
+ result = subprocess.run(
596
+ [sys.executable, HOOK_SCRIPT_PATH],
597
+ input=payload,
598
+ capture_output=True,
599
+ text=True,
600
+ check=False,
601
+ )
602
+ assert result.returncode == 0
603
+ assert result.stdout == ""
604
+
605
+
606
+ def test_handles_non_string_tool_name():
607
+ """A tool_name that is not a string should exit cleanly — the hook must
608
+ guard against tool_name not being a string."""
609
+ payload = json.dumps({"tool_name": 123, "tool_input": {"file_path": "src/main.py"}})
610
+ result = subprocess.run(
611
+ [sys.executable, HOOK_SCRIPT_PATH],
612
+ input=payload,
613
+ capture_output=True,
614
+ text=True,
615
+ check=False,
616
+ )
617
+ assert result.returncode == 0
618
+ assert result.stdout == ""