claude-dev-env 2.30.1 → 2.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -1,4 +1,11 @@
1
- """Tests for state_description_blocker hook."""
1
+ """Tests for state_description_blocker hook.
2
+
3
+ Every payload here names an absolute file under this repository and carries
4
+ the repository root as its ``cwd``, so each decision belongs to this
5
+ checkout rather than to the directory pytest runs from. The one exception is
6
+ the ephemeral-path test, which keeps its scratch target outside the
7
+ repository root on purpose.
8
+ """
2
9
 
3
10
  import json
4
11
  import os
@@ -26,6 +33,28 @@ HOOK_SCRIPT_PATH = os.path.join(
26
33
  os.path.dirname(__file__), "state_description_blocker.py"
27
34
  )
28
35
 
36
+ _REPOSITORY_ROOT = str(Path(__file__).resolve().parents[4])
37
+
38
+
39
+ def _repository_file_path(relative_path: str) -> str:
40
+ """Return the absolute path of a repository-relative file in this checkout.
41
+
42
+ The hook resolves a bare relative path against the process working
43
+ directory, so a payload carrying one answers to the directory pytest was
44
+ invoked from. An absolute path under the repository root, paired with a
45
+ payload ``cwd`` naming that root, keeps the decision stable for a
46
+ checkout under a temporary root as well.
47
+
48
+ Args:
49
+ relative_path: The path of the target file relative to the repository
50
+ root.
51
+
52
+ Returns:
53
+ The absolute path of that file inside this repository.
54
+ """
55
+ return str(Path(_REPOSITORY_ROOT) / relative_path)
56
+
57
+
29
58
  CLEAN_PYTHON = "x = 1 # Uses a default timeout"
30
59
  CLEAN_MD = "# Config\n\nThe API uses port 8080."
31
60
  CLEAN_COMMENT = "# Configured with a 30-second timeout"
@@ -46,7 +75,11 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
46
75
 
47
76
 
48
77
  class _RunHook:
49
- """Helper to test the hook via subprocess."""
78
+ """Run the hook as a subprocess on a payload anchored in this repository.
79
+
80
+ The payload names the repository root as the session working directory,
81
+ so the hook treats each target inside it as a real production file.
82
+ """
50
83
 
51
84
  def __call__(
52
85
  self,
@@ -55,7 +88,9 @@ class _RunHook:
55
88
  *,
56
89
  is_prose_style_enabled: bool = True,
57
90
  ) -> subprocess.CompletedProcess:
58
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
91
+ payload = json.dumps(
92
+ {"tool_name": tool_name, "tool_input": tool_input, "cwd": _REPOSITORY_ROOT}
93
+ )
59
94
  environment_by_key = os.environ.copy()
60
95
  if is_prose_style_enabled:
61
96
  environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
@@ -81,7 +116,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
81
116
  result = _run_hook(
82
117
  "Write",
83
118
  {
84
- "file_path": "src/main.py",
119
+ "file_path": _repository_file_path("src/main.py"),
85
120
  "content": VIOLATION_INSTEAD_OF_COMMENT,
86
121
  },
87
122
  is_prose_style_enabled=False,
@@ -92,9 +127,10 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
92
127
  {
93
128
  "tool_name": "Write",
94
129
  "tool_input": {
95
- "file_path": "src/main.py",
130
+ "file_path": _repository_file_path("src/main.py"),
96
131
  "content": VIOLATION_INSTEAD_OF_COMMENT,
97
132
  },
133
+ "cwd": _REPOSITORY_ROOT,
98
134
  }
99
135
  )
100
136
  assert deny_reason is not None
@@ -105,7 +141,7 @@ def test_block_clean_python_comment_passes():
105
141
  result = _run_hook(
106
142
  "Write",
107
143
  {
108
- "file_path": "src/main.py",
144
+ "file_path": _repository_file_path("src/main.py"),
109
145
  "content": CLEAN_PYTHON,
110
146
  },
111
147
  )
@@ -117,7 +153,7 @@ def test_block_clean_markdown_passes():
117
153
  result = _run_hook(
118
154
  "Write",
119
155
  {
120
- "file_path": "docs/README.md",
156
+ "file_path": _repository_file_path("docs/README.md"),
121
157
  "content": CLEAN_MD,
122
158
  },
123
159
  )
@@ -129,7 +165,7 @@ def test_block_clean_comment_passes():
129
165
  result = _run_hook(
130
166
  "Write",
131
167
  {
132
- "file_path": "src/config.py",
168
+ "file_path": _repository_file_path("src/config.py"),
133
169
  "content": CLEAN_COMMENT,
134
170
  },
135
171
  )
@@ -141,7 +177,7 @@ def test_block_irrelevant_file_passes():
141
177
  result = _run_hook(
142
178
  "Write",
143
179
  {
144
- "file_path": "data.txt",
180
+ "file_path": _repository_file_path("data.txt"),
145
181
  "content": VIOLATION_INSTEAD_OF_COMMENT,
146
182
  },
147
183
  )
@@ -153,7 +189,7 @@ def test_block_empty_content_passes():
153
189
  result = _run_hook(
154
190
  "Write",
155
191
  {
156
- "file_path": "src/main.py",
192
+ "file_path": _repository_file_path("src/main.py"),
157
193
  "content": "",
158
194
  },
159
195
  )
@@ -177,7 +213,7 @@ def test_detects_instead_of_in_comment():
177
213
  result = _run_hook(
178
214
  "Write",
179
215
  {
180
- "file_path": "src/main.py",
216
+ "file_path": _repository_file_path("src/main.py"),
181
217
  "content": VIOLATION_INSTEAD_OF_COMMENT,
182
218
  },
183
219
  )
@@ -191,7 +227,7 @@ def test_detects_previously_in_comment():
191
227
  result = _run_hook(
192
228
  "Write",
193
229
  {
194
- "file_path": "src/config.py",
230
+ "file_path": _repository_file_path("src/config.py"),
195
231
  "content": VIOLATION_PREVIOUSLY_COMMENT,
196
232
  },
197
233
  )
@@ -205,7 +241,7 @@ def test_detects_now_uses_in_comment():
205
241
  result = _run_hook(
206
242
  "Write",
207
243
  {
208
- "file_path": "src/client.py",
244
+ "file_path": _repository_file_path("src/client.py"),
209
245
  "content": VIOLATION_NOW_USES_COMMENT,
210
246
  },
211
247
  )
@@ -219,7 +255,7 @@ def test_detects_instead_of_in_markdown():
219
255
  result = _run_hook(
220
256
  "Write",
221
257
  {
222
- "file_path": "docs/api.md",
258
+ "file_path": _repository_file_path("docs/api.md"),
223
259
  "content": VIOLATION_MD_INSTEAD,
224
260
  },
225
261
  )
@@ -232,7 +268,7 @@ def test_detects_previously_in_markdown():
232
268
  result = _run_hook(
233
269
  "Write",
234
270
  {
235
- "file_path": "docs/config.md",
271
+ "file_path": _repository_file_path("docs/config.md"),
236
272
  "content": VIOLATION_MD_PREVIOUSLY,
237
273
  },
238
274
  )
@@ -245,7 +281,7 @@ def test_detects_now_uses_in_markdown():
245
281
  result = _run_hook(
246
282
  "Write",
247
283
  {
248
- "file_path": "docs/auth.md",
284
+ "file_path": _repository_file_path("docs/auth.md"),
249
285
  "content": VIOLATION_MD_NOW_USES,
250
286
  },
251
287
  )
@@ -271,7 +307,7 @@ def test_detects_edit_new_string():
271
307
  result = _run_hook(
272
308
  "Edit",
273
309
  {
274
- "file_path": "src/main.py",
310
+ "file_path": _repository_file_path("src/main.py"),
275
311
  "old_string": "old_comment",
276
312
  "new_string": VIOLATION_PREVIOUSLY_COMMENT,
277
313
  },
@@ -286,7 +322,7 @@ def test_clean_edit_passes():
286
322
  result = _run_hook(
287
323
  "Edit",
288
324
  {
289
- "file_path": "src/main.py",
325
+ "file_path": _repository_file_path("src/main.py"),
290
326
  "old_string": "x = 1",
291
327
  "new_string": CLEAN_PYTHON,
292
328
  },
@@ -295,11 +331,79 @@ def test_clean_edit_passes():
295
331
  assert result.stdout == ""
296
332
 
297
333
 
334
+ def test_detects_multi_edit_second_new_string() -> None:
335
+ """A violation in the second edit of a MultiEdit is denied, not only the first."""
336
+ result = _run_hook(
337
+ "MultiEdit",
338
+ {
339
+ "file_path": _repository_file_path("src/main.py"),
340
+ "edits": [
341
+ {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
342
+ {"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
343
+ ],
344
+ },
345
+ )
346
+ assert result.returncode == 0
347
+ output = json.loads(result.stdout)
348
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
349
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
350
+
351
+
352
+ def test_clean_multi_edit_passes() -> None:
353
+ result = _run_hook(
354
+ "MultiEdit",
355
+ {
356
+ "file_path": _repository_file_path("src/main.py"),
357
+ "edits": [
358
+ {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
359
+ {"old_string": "b = 2", "new_string": CLEAN_COMMENT},
360
+ ],
361
+ },
362
+ )
363
+ assert result.returncode == 0
364
+ assert result.stdout == ""
365
+
366
+
367
+ def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
368
+ """Two clean edit fragments stay clean even when one ends where the next begins.
369
+
370
+ The first edit's new_string ends in "# describes the previous", and the
371
+ second's begins with "ly implemented step.". Concatenated with no
372
+ separator the two would spell "previously" and trip a false deny; joined
373
+ on a real separator they stay two distinct fragments.
374
+ """
375
+ result = _run_hook(
376
+ "MultiEdit",
377
+ {
378
+ "file_path": _repository_file_path("src/main.py"),
379
+ "edits": [
380
+ {"old_string": "a = 1", "new_string": "# describes the previous"},
381
+ {"old_string": "b = 2", "new_string": "ly implemented step."},
382
+ ],
383
+ },
384
+ )
385
+ assert result.returncode == 0
386
+ assert result.stdout == ""
387
+
388
+
389
+ def test_multi_edit_with_a_single_edit_still_denies() -> None:
390
+ result = _run_hook(
391
+ "MultiEdit",
392
+ {
393
+ "file_path": _repository_file_path("src/main.py"),
394
+ "edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
395
+ },
396
+ )
397
+ assert result.returncode == 0
398
+ output = json.loads(result.stdout)
399
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
400
+
401
+
298
402
  def test_system_message_and_suppress_output():
299
403
  result = _run_hook(
300
404
  "Write",
301
405
  {
302
- "file_path": "src/main.py",
406
+ "file_path": _repository_file_path("src/main.py"),
303
407
  "content": VIOLATION_INSTEAD_OF_COMMENT,
304
408
  },
305
409
  )
@@ -314,7 +418,7 @@ def test_detects_no_longer_in_comment():
314
418
  result = _run_hook(
315
419
  "Write",
316
420
  {
317
- "file_path": "src/config.py",
421
+ "file_path": _repository_file_path("src/config.py"),
318
422
  "content": "# No longer supports legacy mode",
319
423
  },
320
424
  )
@@ -328,7 +432,7 @@ def test_detects_used_to_in_comment():
328
432
  result = _run_hook(
329
433
  "Write",
330
434
  {
331
- "file_path": "src/config.py",
435
+ "file_path": _repository_file_path("src/config.py"),
332
436
  "content": "# Used to be hardcoded",
333
437
  },
334
438
  )
@@ -342,7 +446,7 @@ def test_detects_switched_to_in_comment():
342
446
  result = _run_hook(
343
447
  "Write",
344
448
  {
345
- "file_path": "src/config.py",
449
+ "file_path": _repository_file_path("src/config.py"),
346
450
  "content": "# Switched to async processing",
347
451
  },
348
452
  )
@@ -360,7 +464,7 @@ def test_detects_comment_with_close_block_token():
360
464
  result = _run_hook(
361
465
  "Write",
362
466
  {
363
- "file_path": "src/config.py",
467
+ "file_path": _repository_file_path("src/config.py"),
364
468
  "content": VIOLATION_COMMENT_WITH_CLOSE_BLOCK,
365
469
  },
366
470
  )
@@ -377,7 +481,7 @@ def test_detects_inline_trailing_comment():
377
481
  result = _run_hook(
378
482
  "Write",
379
483
  {
380
- "file_path": "src/main.py",
484
+ "file_path": _repository_file_path("src/main.py"),
381
485
  "content": "max_retries = 3 # No longer needed since async retry handles it",
382
486
  },
383
487
  )
@@ -393,7 +497,7 @@ def test_ignores_c_preprocessor_directive():
393
497
  result = _run_hook(
394
498
  "Write",
395
499
  {
396
- "file_path": "src/config.h",
500
+ "file_path": _repository_file_path("src/config.h"),
397
501
  "content": '#error "This code previously used replaced API"',
398
502
  },
399
503
  )
@@ -409,7 +513,7 @@ def test_ignores_hash_in_javascript_inline():
409
513
  result = _run_hook(
410
514
  "Write",
411
515
  {
412
- "file_path": "src/main.js",
516
+ "file_path": _repository_file_path("src/main.js"),
413
517
  "content": 'const selector = "#originally-dark" // Use dark as default',
414
518
  },
415
519
  )
@@ -425,7 +529,7 @@ def test_ignores_double_slash_in_js_url():
425
529
  result = _run_hook(
426
530
  "Write",
427
531
  {
428
- "file_path": "src/fetch.ts",
532
+ "file_path": _repository_file_path("src/fetch.ts"),
429
533
  "content": 'fetch("https://api.example.com/replaces")',
430
534
  },
431
535
  )
@@ -441,7 +545,7 @@ def test_block_comment_continuation_with_nested_glob():
441
545
  result = _run_hook(
442
546
  "Write",
443
547
  {
444
- "file_path": "src/cache.ts",
548
+ "file_path": _repository_file_path("src/cache.ts"),
445
549
  "content": content,
446
550
  },
447
551
  )
@@ -458,7 +562,7 @@ def test_detects_inline_after_url_on_same_line():
458
562
  result = _run_hook(
459
563
  "Write",
460
564
  {
461
- "file_path": "src/fetch.ts",
565
+ "file_path": _repository_file_path("src/fetch.ts"),
462
566
  "content": 'const url = "https://api.com"; // no longer used',
463
567
  },
464
568
  )
@@ -475,7 +579,7 @@ def test_ignores_code_before_block_comment():
475
579
  result = _run_hook(
476
580
  "Write",
477
581
  {
478
- "file_path": "src/cache.ts",
582
+ "file_path": _repository_file_path("src/cache.ts"),
479
583
  "content": "cache.replaces(old); /* Use fresh cache */",
480
584
  },
481
585
  )
@@ -491,7 +595,7 @@ def test_ignores_url_with_double_slash_in_python():
491
595
  result = _run_hook(
492
596
  "Write",
493
597
  {
494
- "file_path": "src/main.py",
598
+ "file_path": _repository_file_path("src/main.py"),
495
599
  "content": 'url = "https://api.example.com/replaces/v1"',
496
600
  },
497
601
  )
@@ -506,7 +610,7 @@ def test_ignores_instead_of_in_code_string_with_inline_comment():
506
610
  result = _run_hook(
507
611
  "Write",
508
612
  {
509
- "file_path": "src/main.py",
613
+ "file_path": _repository_file_path("src/main.py"),
510
614
  "content": "msg = 'instead of' # Use the default timeout",
511
615
  },
512
616
  )
@@ -522,7 +626,7 @@ def test_ignores_glob_pattern_with_star_in_python():
522
626
  result = _run_hook(
523
627
  "Write",
524
628
  {
525
- "file_path": "src/main.py",
629
+ "file_path": _repository_file_path("src/main.py"),
526
630
  "content": content,
527
631
  },
528
632
  )
@@ -538,7 +642,7 @@ def test_ignores_comment_with_glob_pattern():
538
642
  result = _run_hook(
539
643
  "Write",
540
644
  {
541
- "file_path": "src/main.py",
645
+ "file_path": _repository_file_path("src/main.py"),
542
646
  "content": content,
543
647
  },
544
648
  )
@@ -553,7 +657,7 @@ def test_single_line_block_comment_closes():
553
657
  result = _run_hook(
554
658
  "Write",
555
659
  {
556
- "file_path": "src/cache.ts",
660
+ "file_path": _repository_file_path("src/cache.ts"),
557
661
  "content": content,
558
662
  },
559
663
  )
@@ -571,7 +675,7 @@ def test_block_comment_with_url_closes_correctly():
571
675
  result = _run_hook(
572
676
  "Write",
573
677
  {
574
- "file_path": "src/cache.ts",
678
+ "file_path": _repository_file_path("src/cache.ts"),
575
679
  "content": content,
576
680
  },
577
681
  )
@@ -588,7 +692,7 @@ def test_same_line_block_comment_with_trailing_inline():
588
692
  result = _run_hook(
589
693
  "Write",
590
694
  {
591
- "file_path": "src/fetch.ts",
695
+ "file_path": _repository_file_path("src/fetch.ts"),
592
696
  "content": content,
593
697
  },
594
698
  )
@@ -606,7 +710,7 @@ def test_multi_line_block_comment_close_with_trailing_inline():
606
710
  result = _run_hook(
607
711
  "Write",
608
712
  {
609
- "file_path": "src/cache.ts",
713
+ "file_path": _repository_file_path("src/cache.ts"),
610
714
  "content": content,
611
715
  },
612
716
  )
@@ -623,7 +727,7 @@ def test_detects_earliest_inline_marker_in_php():
623
727
  result = _run_hook(
624
728
  "Write",
625
729
  {
626
- "file_path": "src/index.php",
730
+ "file_path": _repository_file_path("src/index.php"),
627
731
  "content": 'echo $x; // previously used #tag',
628
732
  },
629
733
  )
@@ -637,7 +741,7 @@ def test_additional_context_contains_examples():
637
741
  result = _run_hook(
638
742
  "Write",
639
743
  {
640
- "file_path": "src/main.py",
744
+ "file_path": _repository_file_path("src/main.py"),
641
745
  "content": VIOLATION_INSTEAD_OF_COMMENT,
642
746
  },
643
747
  )
@@ -682,7 +786,13 @@ def test_handles_non_dict_tool_input():
682
786
  def test_handles_non_string_tool_name():
683
787
  """A tool_name that is not a string should exit cleanly — the hook must
684
788
  guard against tool_name not being a string."""
685
- payload = json.dumps({"tool_name": 123, "tool_input": {"file_path": "src/main.py"}})
789
+ payload = json.dumps(
790
+ {
791
+ "tool_name": 123,
792
+ "tool_input": {"file_path": _repository_file_path("src/main.py")},
793
+ "cwd": _REPOSITORY_ROOT,
794
+ }
795
+ )
686
796
  result = subprocess.run(
687
797
  [sys.executable, HOOK_SCRIPT_PATH],
688
798
  input=payload,
@@ -706,7 +816,7 @@ def test_detects_used_to_in_python_module_docstring() -> None:
706
816
  result = _run_hook(
707
817
  "Write",
708
818
  {
709
- "file_path": "tests/test_exports.py",
819
+ "file_path": _repository_file_path("tests/test_exports.py"),
710
820
  "content": written_source,
711
821
  },
712
822
  )
@@ -727,7 +837,7 @@ def test_detects_previously_in_python_function_docstring() -> None:
727
837
  result = _run_hook(
728
838
  "Write",
729
839
  {
730
- "file_path": "src/configuration.py",
840
+ "file_path": _repository_file_path("src/configuration.py"),
731
841
  "content": written_source,
732
842
  },
733
843
  )
@@ -744,7 +854,7 @@ def test_ignores_transitional_phrase_in_regular_python_string() -> None:
744
854
  result = _run_hook(
745
855
  "Write",
746
856
  {
747
- "file_path": "src/messages.py",
857
+ "file_path": _repository_file_path("src/messages.py"),
748
858
  "content": 'PADDING_NOTE = "used to pad with a flat sleep"\n',
749
859
  },
750
860
  )
@@ -763,7 +873,7 @@ def test_ignores_quoted_mention_inside_python_docstring() -> None:
763
873
  result = _run_hook(
764
874
  "Write",
765
875
  {
766
- "file_path": "src/scanner.py",
876
+ "file_path": _repository_file_path("src/scanner.py"),
767
877
  "content": written_source,
768
878
  },
769
879
  )
@@ -779,7 +889,7 @@ def test_detects_docstring_at_start_of_unparseable_edit_fragment() -> None:
779
889
  result = _run_hook(
780
890
  "Edit",
781
891
  {
782
- "file_path": "src/exports.py",
892
+ "file_path": _repository_file_path("src/exports.py"),
783
893
  "old_string": "placeholder",
784
894
  "new_string": fragment,
785
895
  },
@@ -801,7 +911,7 @@ def test_detects_docstring_after_def_header_in_unparseable_fragment() -> None:
801
911
  result = _run_hook(
802
912
  "Edit",
803
913
  {
804
- "file_path": "src/reporting.py",
914
+ "file_path": _repository_file_path("src/reporting.py"),
805
915
  "old_string": "placeholder",
806
916
  "new_string": fragment,
807
917
  },
@@ -820,7 +930,7 @@ def test_ignores_assigned_triple_quoted_string_in_unparseable_fragment() -> None
820
930
  result = _run_hook(
821
931
  "Edit",
822
932
  {
823
- "file_path": "src/fixtures.py",
933
+ "file_path": _repository_file_path("src/fixtures.py"),
824
934
  "old_string": "placeholder",
825
935
  "new_string": fragment,
826
936
  },
@@ -839,7 +949,11 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
839
949
  """
840
950
  deny_payload = {
841
951
  "tool_name": "Write",
842
- "tool_input": {"file_path": "src/main.py", "content": VIOLATION_INSTEAD_OF_COMMENT},
952
+ "tool_input": {
953
+ "file_path": _repository_file_path("src/main.py"),
954
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
955
+ },
956
+ "cwd": _REPOSITORY_ROOT,
843
957
  }
844
958
  native_hook = NativeHook(evaluate=evaluate, build_deny_payload=build_deny_payload)
845
959
 
@@ -586,3 +586,70 @@ def test_full_hook_denies_edit_that_adds_a_timeout_to_a_non_budget_helper(
586
586
  hook_output = json.loads(completed_hook.stdout)
587
587
  assert hook_output["hookSpecificOutput"]["permissionDecision"] == "deny"
588
588
  assert "45s" in hook_output["hookSpecificOutput"]["permissionDecisionReason"]
589
+
590
+
591
+ _HARMLESS_COMMENT_EDIT = {
592
+ "old_string": "return git_check.returncode != 0\n",
593
+ "new_string": "return git_check.returncode != 0 # checked\n",
594
+ }
595
+ _UNCOUNTED_TIMEOUT_EDIT = {
596
+ "old_string": (
597
+ ' git_check = subprocess.run(["git", "ls-files", file_path],'
598
+ " timeout=GIT_CHECK_TIMEOUT_SECONDS)\n"
599
+ ),
600
+ "new_string": ' git_check = subprocess.run(["git", "ls-files", file_path], timeout=45)\n',
601
+ }
602
+
603
+
604
+ def _run_multi_edit_hook(edited_module_path: pathlib.Path, all_edits: list[dict[str, str]]):
605
+ hook_input = json.dumps(
606
+ {
607
+ "tool_name": "MultiEdit",
608
+ "tool_input": {"file_path": str(edited_module_path), "edits": all_edits},
609
+ }
610
+ )
611
+ return subprocess.run(
612
+ [sys.executable, str(_HOOK_DIR / "subprocess_budget_completeness.py")],
613
+ input=hook_input,
614
+ capture_output=True,
615
+ text=True,
616
+ timeout=15,
617
+ check=False,
618
+ )
619
+
620
+
621
+ def test_resolved_content_reconstructs_the_full_file_for_a_multi_edit(
622
+ tmp_path: pathlib.Path,
623
+ ) -> None:
624
+ """The whole reconstructed file reflects every edit, not only the first."""
625
+ edited_module_path = tmp_path / "timing_module.py"
626
+ edited_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
627
+ reconstructed_content = resolved_content(
628
+ {
629
+ "file_path": str(edited_module_path),
630
+ "edits": [_HARMLESS_COMMENT_EDIT, _UNCOUNTED_TIMEOUT_EDIT],
631
+ }
632
+ )
633
+ assert "timeout=45" in reconstructed_content
634
+ assert "# checked" in reconstructed_content
635
+
636
+
637
+ def test_full_hook_denies_multi_edit_that_adds_a_timeout_in_the_second_edit(
638
+ production_module_path: pathlib.Path,
639
+ ) -> None:
640
+ """A new timeout introduced by the second edit of a MultiEdit is caught."""
641
+ production_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
642
+ completed_hook = _run_multi_edit_hook(
643
+ production_module_path, [_HARMLESS_COMMENT_EDIT, _UNCOUNTED_TIMEOUT_EDIT]
644
+ )
645
+ assert completed_hook.returncode == 0
646
+ hook_output = json.loads(completed_hook.stdout)
647
+ assert hook_output["hookSpecificOutput"]["permissionDecision"] == "deny"
648
+ assert "45s" in hook_output["hookSpecificOutput"]["permissionDecisionReason"]
649
+
650
+
651
+ def test_full_hook_allows_a_clean_multi_edit(production_module_path: pathlib.Path) -> None:
652
+ production_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
653
+ completed_hook = _run_multi_edit_hook(production_module_path, [_HARMLESS_COMMENT_EDIT])
654
+ assert completed_hook.returncode == 0
655
+ assert completed_hook.stdout == ""