claude-dev-env 2.31.1 → 2.33.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 (139) hide show
  1. package/.agents/agents/clean-coder.md +8 -7
  2. package/.agents/agents/code-quality-agent.md +4 -2
  3. package/.agents/agents/pr-description-writer.md +8 -3
  4. package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
  5. package/.agents/agents/test_agent_frontmatter.py +0 -9
  6. package/.agents/agents/test_pr_description_writer_style.py +24 -1
  7. package/.agents/skills/e-simplify/SKILL.md +1 -15
  8. package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
  9. package/.agents/skills/pr-refinement/SKILL.md +2 -2
  10. package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
  11. package/.agents/skills/pr-title-description/SKILL.md +6 -2
  12. package/.agents/skills/review-tier/SKILL.md +1 -1
  13. package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
  14. package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
  15. package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
  16. package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
  17. package/AGENTS.md +83 -0
  18. package/_shared/pr-loop/code-rules-gate.md +5 -2
  19. package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
  22. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
  23. package/audit-rubrics/audit-categories.json +2 -2
  24. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
  25. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
  26. package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
  27. package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
  28. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
  29. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
  30. package/bin/install.test.mjs +11 -3
  31. package/docs/CODE_RULES.md +8 -9
  32. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  33. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  34. package/hooks/blocking/code_rules_comments.py +118 -59
  35. package/hooks/blocking/code_rules_duplicate_body.py +13 -7
  36. package/hooks/blocking/code_rules_enforcer.py +19 -7
  37. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  38. package/hooks/blocking/code_rules_type_escape.py +16 -23
  39. package/hooks/blocking/javascript_comment_scanner.py +99 -0
  40. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  41. package/hooks/blocking/stop_dispatcher.py +6 -9
  42. package/hooks/blocking/tdd_enforcer.py +3 -1
  43. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  44. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  45. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  46. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  47. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
  48. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  49. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
  50. package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
  51. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
  52. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
  53. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
  54. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  55. package/hooks/blocking/test_state_description_blocker.py +56 -71
  56. package/hooks/blocking/test_tdd_enforcer.py +67 -1
  57. package/hooks/git-hooks/pull_request_handoff.py +1 -1
  58. package/hooks/git-hooks/test_post_commit.py +8 -0
  59. package/hooks/hooks.json +2 -2
  60. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  61. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  62. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  63. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  64. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
  65. package/hooks/hooks_constants/state_file_lock.py +76 -0
  66. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  67. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  68. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  69. package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
  70. package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
  71. package/hooks/observability/session_file_edit_tracker.py +2 -55
  72. package/hooks/observability/test_failure_recorder.py +219 -0
  73. package/hooks/observability/test_test_failure_recorder.py +173 -0
  74. package/hooks/validators/comment_checks.py +2 -2
  75. package/hooks/validators/config/directory_exemption_constants.py +0 -9
  76. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  77. package/hooks/validators/fast_save_validators.py +0 -9
  78. package/hooks/validators/git_checks.py +0 -84
  79. package/hooks/validators/mypy_integration.py +10 -36
  80. package/hooks/validators/project_roots.py +58 -0
  81. package/hooks/validators/run_all_validators.py +89 -42
  82. package/hooks/validators/system_temporary_roots.py +1 -1
  83. package/hooks/validators/test_fast_save_validators.py +2 -8
  84. package/hooks/validators/test_git_checks.py +0 -249
  85. package/hooks/validators/test_run_all_validators.py +22 -0
  86. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  87. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  88. package/hooks/validators/test_todo_checks.py +1 -0
  89. package/hooks/validators/todo_checks.py +1 -1
  90. package/package.json +1 -1
  91. package/rules/git-workflow.md +0 -8
  92. package/rules/shell-invocation.md +1 -1
  93. package/rules/testing.md +4 -0
  94. package/scripts/check.ps1 +35 -1
  95. package/scripts/codex_compat_materializer.py +1 -0
  96. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
  97. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  98. package/scripts/shared_tree_paths.py +59 -25
  99. package/scripts/test_shared_tree_paths.py +30 -0
  100. package/scripts/tests/test_rules.py +111 -0
  101. package/system-prompts/software-engineer.xml +6 -19
  102. package/.agents/skills-archived/split-pr/SKILL.md +0 -85
  103. package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
  104. package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
  105. package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
  106. package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
  107. package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
  108. package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
  109. package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
  110. package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
  111. package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
  112. package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
  113. package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
  114. package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
  115. package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
  116. package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
  117. package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
  118. package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
  119. package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
  120. package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
  121. package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
  122. package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
  123. package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
  124. package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
  125. package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
  126. package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
  127. package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
  128. package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
  129. package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
  130. package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
  131. package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
  132. package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
  133. package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
  134. package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
  135. package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
  136. package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
  137. package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
  138. package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
  139. package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
@@ -191,3 +191,108 @@ def test_denies_nested_command_substitution_without_arithmetic() -> None:
191
191
  }
192
192
  response = json.loads(_run_hook(payload).stdout)
193
193
  assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
194
+
195
+
196
+ def test_allows_backtick_inside_single_quoted_heredoc_body() -> None:
197
+ payload = {
198
+ "tool_name": "Bash",
199
+ "tool_input": {
200
+ "command": (
201
+ "cat > notes.md <<'EOF'\n"
202
+ "reStructuredText markup: ``inert literal``\n"
203
+ "EOF"
204
+ )
205
+ },
206
+ }
207
+ assert _run_hook(payload).stdout == ""
208
+
209
+
210
+ def test_allows_backtick_inside_tab_stripped_quoted_heredoc_body() -> None:
211
+ """A ``<<-`` opener closes on a tab-indented terminator, and stays inert.
212
+
213
+ The tab-strip rule decides which line ends the body. Getting it wrong
214
+ swallows the rest of the command or ends the body early, so the branch is
215
+ pinned here as well as in the shared pipeline that owns the rule.
216
+ """
217
+ payload = {
218
+ "tool_name": "Bash",
219
+ "tool_input": {
220
+ "command": (
221
+ "cat > notes.md <<-'EOF'\n"
222
+ "reStructuredText markup: ``inert literal``\n"
223
+ "\tEOF\n"
224
+ "echo done"
225
+ )
226
+ },
227
+ }
228
+ assert _run_hook(payload).stdout == ""
229
+
230
+
231
+ def test_denies_backtick_inside_bare_heredoc_body() -> None:
232
+ payload = {
233
+ "tool_name": "Bash",
234
+ "tool_input": {"command": "cat > notes.md <<EOF\nnow: `date`\nEOF"},
235
+ }
236
+ response = json.loads(_run_hook(payload).stdout)
237
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
238
+
239
+
240
+ def test_denies_command_substitution_inside_bare_heredoc_body() -> None:
241
+ payload = {
242
+ "tool_name": "Bash",
243
+ "tool_input": {
244
+ "command": "cat > notes.md <<EOF\nhead: $(git rev-parse HEAD)\nEOF"
245
+ },
246
+ }
247
+ response = json.loads(_run_hook(payload).stdout)
248
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
249
+
250
+
251
+ def test_denies_live_substitution_outside_a_quoted_heredoc() -> None:
252
+ payload = {
253
+ "tool_name": "Bash",
254
+ "tool_input": {
255
+ "command": (
256
+ "cat > notes.md <<'EOF'\n"
257
+ "inert ``literal``\n"
258
+ "EOF\n"
259
+ 'echo "$(git rev-parse HEAD)"'
260
+ )
261
+ },
262
+ }
263
+ response = json.loads(_run_hook(payload).stdout)
264
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
265
+
266
+
267
+ def test_allows_backslash_escaped_heredoc_delimiter_body() -> None:
268
+ payload = {
269
+ "tool_name": "Bash",
270
+ "tool_input": {
271
+ "command": "cat > notes.md <<\\EOF\ninert `text`\nEOF"
272
+ },
273
+ }
274
+ assert _run_hook(payload).stdout == ""
275
+
276
+
277
+ def test_denies_a_substitution_below_an_opener_inside_a_quoted_string() -> None:
278
+ """An opener spelled inside a quoted string opens no heredoc.
279
+
280
+ Bash runs the second line here. Reading the first line as an opener and
281
+ dropping everything below it hides a live substitution from the scan.
282
+ """
283
+ payload = {
284
+ "tool_name": "Bash",
285
+ "tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)'},
286
+ }
287
+ response = json.loads(_run_hook(payload).stdout)
288
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
289
+
290
+
291
+ def test_denies_that_bypass_even_when_a_terminator_line_follows() -> None:
292
+ """A terminator line below the substitution does not make the strip safe."""
293
+ payload = {
294
+ "tool_name": "Bash",
295
+ "tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)\nEOF'},
296
+ }
297
+ response = json.loads(_run_hook(payload).stdout)
298
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
@@ -29,31 +29,16 @@ from state_description_blocker import ( # noqa: E402
29
29
  )
30
30
  from state_description_blocker import evaluate as _evaluate_without_opt_in # noqa: E402
31
31
 
32
+ from code_rules_enforcer_test_support import (
33
+ repository_file_path,
34
+ repository_root,
35
+ )
36
+
32
37
  HOOK_SCRIPT_PATH = os.path.join(
33
38
  os.path.dirname(__file__), "state_description_blocker.py"
34
39
  )
35
40
 
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
-
41
+ _REPOSITORY_ROOT = str(repository_root())
57
42
 
58
43
  CLEAN_PYTHON = "x = 1 # Uses a default timeout"
59
44
  CLEAN_MD = "# Config\n\nThe API uses port 8080."
@@ -116,7 +101,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
116
101
  result = _run_hook(
117
102
  "Write",
118
103
  {
119
- "file_path": _repository_file_path("src/main.py"),
104
+ "file_path": repository_file_path("src/main.py"),
120
105
  "content": VIOLATION_INSTEAD_OF_COMMENT,
121
106
  },
122
107
  is_prose_style_enabled=False,
@@ -127,7 +112,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
127
112
  {
128
113
  "tool_name": "Write",
129
114
  "tool_input": {
130
- "file_path": _repository_file_path("src/main.py"),
115
+ "file_path": repository_file_path("src/main.py"),
131
116
  "content": VIOLATION_INSTEAD_OF_COMMENT,
132
117
  },
133
118
  "cwd": _REPOSITORY_ROOT,
@@ -141,7 +126,7 @@ def test_block_clean_python_comment_passes():
141
126
  result = _run_hook(
142
127
  "Write",
143
128
  {
144
- "file_path": _repository_file_path("src/main.py"),
129
+ "file_path": repository_file_path("src/main.py"),
145
130
  "content": CLEAN_PYTHON,
146
131
  },
147
132
  )
@@ -153,7 +138,7 @@ def test_block_clean_markdown_passes():
153
138
  result = _run_hook(
154
139
  "Write",
155
140
  {
156
- "file_path": _repository_file_path("docs/README.md"),
141
+ "file_path": repository_file_path("docs/README.md"),
157
142
  "content": CLEAN_MD,
158
143
  },
159
144
  )
@@ -165,7 +150,7 @@ def test_block_clean_comment_passes():
165
150
  result = _run_hook(
166
151
  "Write",
167
152
  {
168
- "file_path": _repository_file_path("src/config.py"),
153
+ "file_path": repository_file_path("src/config.py"),
169
154
  "content": CLEAN_COMMENT,
170
155
  },
171
156
  )
@@ -177,7 +162,7 @@ def test_block_irrelevant_file_passes():
177
162
  result = _run_hook(
178
163
  "Write",
179
164
  {
180
- "file_path": _repository_file_path("data.txt"),
165
+ "file_path": repository_file_path("data.txt"),
181
166
  "content": VIOLATION_INSTEAD_OF_COMMENT,
182
167
  },
183
168
  )
@@ -189,7 +174,7 @@ def test_block_empty_content_passes():
189
174
  result = _run_hook(
190
175
  "Write",
191
176
  {
192
- "file_path": _repository_file_path("src/main.py"),
177
+ "file_path": repository_file_path("src/main.py"),
193
178
  "content": "",
194
179
  },
195
180
  )
@@ -213,7 +198,7 @@ def test_detects_instead_of_in_comment():
213
198
  result = _run_hook(
214
199
  "Write",
215
200
  {
216
- "file_path": _repository_file_path("src/main.py"),
201
+ "file_path": repository_file_path("src/main.py"),
217
202
  "content": VIOLATION_INSTEAD_OF_COMMENT,
218
203
  },
219
204
  )
@@ -227,7 +212,7 @@ def test_detects_previously_in_comment():
227
212
  result = _run_hook(
228
213
  "Write",
229
214
  {
230
- "file_path": _repository_file_path("src/config.py"),
215
+ "file_path": repository_file_path("src/config.py"),
231
216
  "content": VIOLATION_PREVIOUSLY_COMMENT,
232
217
  },
233
218
  )
@@ -241,7 +226,7 @@ def test_detects_now_uses_in_comment():
241
226
  result = _run_hook(
242
227
  "Write",
243
228
  {
244
- "file_path": _repository_file_path("src/client.py"),
229
+ "file_path": repository_file_path("src/client.py"),
245
230
  "content": VIOLATION_NOW_USES_COMMENT,
246
231
  },
247
232
  )
@@ -255,7 +240,7 @@ def test_detects_instead_of_in_markdown():
255
240
  result = _run_hook(
256
241
  "Write",
257
242
  {
258
- "file_path": _repository_file_path("docs/api.md"),
243
+ "file_path": repository_file_path("docs/api.md"),
259
244
  "content": VIOLATION_MD_INSTEAD,
260
245
  },
261
246
  )
@@ -268,7 +253,7 @@ def test_detects_previously_in_markdown():
268
253
  result = _run_hook(
269
254
  "Write",
270
255
  {
271
- "file_path": _repository_file_path("docs/config.md"),
256
+ "file_path": repository_file_path("docs/config.md"),
272
257
  "content": VIOLATION_MD_PREVIOUSLY,
273
258
  },
274
259
  )
@@ -281,7 +266,7 @@ def test_detects_now_uses_in_markdown():
281
266
  result = _run_hook(
282
267
  "Write",
283
268
  {
284
- "file_path": _repository_file_path("docs/auth.md"),
269
+ "file_path": repository_file_path("docs/auth.md"),
285
270
  "content": VIOLATION_MD_NOW_USES,
286
271
  },
287
272
  )
@@ -307,7 +292,7 @@ def test_detects_edit_new_string():
307
292
  result = _run_hook(
308
293
  "Edit",
309
294
  {
310
- "file_path": _repository_file_path("src/main.py"),
295
+ "file_path": repository_file_path("src/main.py"),
311
296
  "old_string": "old_comment",
312
297
  "new_string": VIOLATION_PREVIOUSLY_COMMENT,
313
298
  },
@@ -322,7 +307,7 @@ def test_clean_edit_passes():
322
307
  result = _run_hook(
323
308
  "Edit",
324
309
  {
325
- "file_path": _repository_file_path("src/main.py"),
310
+ "file_path": repository_file_path("src/main.py"),
326
311
  "old_string": "x = 1",
327
312
  "new_string": CLEAN_PYTHON,
328
313
  },
@@ -336,7 +321,7 @@ def test_detects_multi_edit_second_new_string() -> None:
336
321
  result = _run_hook(
337
322
  "MultiEdit",
338
323
  {
339
- "file_path": _repository_file_path("src/main.py"),
324
+ "file_path": repository_file_path("src/main.py"),
340
325
  "edits": [
341
326
  {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
342
327
  {"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
@@ -353,7 +338,7 @@ def test_clean_multi_edit_passes() -> None:
353
338
  result = _run_hook(
354
339
  "MultiEdit",
355
340
  {
356
- "file_path": _repository_file_path("src/main.py"),
341
+ "file_path": repository_file_path("src/main.py"),
357
342
  "edits": [
358
343
  {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
359
344
  {"old_string": "b = 2", "new_string": CLEAN_COMMENT},
@@ -375,7 +360,7 @@ def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
375
360
  result = _run_hook(
376
361
  "MultiEdit",
377
362
  {
378
- "file_path": _repository_file_path("src/main.py"),
363
+ "file_path": repository_file_path("src/main.py"),
379
364
  "edits": [
380
365
  {"old_string": "a = 1", "new_string": "# describes the previous"},
381
366
  {"old_string": "b = 2", "new_string": "ly implemented step."},
@@ -390,7 +375,7 @@ def test_multi_edit_with_a_single_edit_still_denies() -> None:
390
375
  result = _run_hook(
391
376
  "MultiEdit",
392
377
  {
393
- "file_path": _repository_file_path("src/main.py"),
378
+ "file_path": repository_file_path("src/main.py"),
394
379
  "edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
395
380
  },
396
381
  )
@@ -403,7 +388,7 @@ def test_system_message_and_suppress_output():
403
388
  result = _run_hook(
404
389
  "Write",
405
390
  {
406
- "file_path": _repository_file_path("src/main.py"),
391
+ "file_path": repository_file_path("src/main.py"),
407
392
  "content": VIOLATION_INSTEAD_OF_COMMENT,
408
393
  },
409
394
  )
@@ -418,7 +403,7 @@ def test_detects_no_longer_in_comment():
418
403
  result = _run_hook(
419
404
  "Write",
420
405
  {
421
- "file_path": _repository_file_path("src/config.py"),
406
+ "file_path": repository_file_path("src/config.py"),
422
407
  "content": "# No longer supports legacy mode",
423
408
  },
424
409
  )
@@ -432,7 +417,7 @@ def test_detects_used_to_in_comment():
432
417
  result = _run_hook(
433
418
  "Write",
434
419
  {
435
- "file_path": _repository_file_path("src/config.py"),
420
+ "file_path": repository_file_path("src/config.py"),
436
421
  "content": "# Used to be hardcoded",
437
422
  },
438
423
  )
@@ -446,7 +431,7 @@ def test_detects_switched_to_in_comment():
446
431
  result = _run_hook(
447
432
  "Write",
448
433
  {
449
- "file_path": _repository_file_path("src/config.py"),
434
+ "file_path": repository_file_path("src/config.py"),
450
435
  "content": "# Switched to async processing",
451
436
  },
452
437
  )
@@ -464,7 +449,7 @@ def test_detects_comment_with_close_block_token():
464
449
  result = _run_hook(
465
450
  "Write",
466
451
  {
467
- "file_path": _repository_file_path("src/config.py"),
452
+ "file_path": repository_file_path("src/config.py"),
468
453
  "content": VIOLATION_COMMENT_WITH_CLOSE_BLOCK,
469
454
  },
470
455
  )
@@ -481,7 +466,7 @@ def test_detects_inline_trailing_comment():
481
466
  result = _run_hook(
482
467
  "Write",
483
468
  {
484
- "file_path": _repository_file_path("src/main.py"),
469
+ "file_path": repository_file_path("src/main.py"),
485
470
  "content": "max_retries = 3 # No longer needed since async retry handles it",
486
471
  },
487
472
  )
@@ -497,7 +482,7 @@ def test_ignores_c_preprocessor_directive():
497
482
  result = _run_hook(
498
483
  "Write",
499
484
  {
500
- "file_path": _repository_file_path("src/config.h"),
485
+ "file_path": repository_file_path("src/config.h"),
501
486
  "content": '#error "This code previously used replaced API"',
502
487
  },
503
488
  )
@@ -513,7 +498,7 @@ def test_ignores_hash_in_javascript_inline():
513
498
  result = _run_hook(
514
499
  "Write",
515
500
  {
516
- "file_path": _repository_file_path("src/main.js"),
501
+ "file_path": repository_file_path("src/main.js"),
517
502
  "content": 'const selector = "#originally-dark" // Use dark as default',
518
503
  },
519
504
  )
@@ -529,7 +514,7 @@ def test_ignores_double_slash_in_js_url():
529
514
  result = _run_hook(
530
515
  "Write",
531
516
  {
532
- "file_path": _repository_file_path("src/fetch.ts"),
517
+ "file_path": repository_file_path("src/fetch.ts"),
533
518
  "content": 'fetch("https://api.example.com/replaces")',
534
519
  },
535
520
  )
@@ -545,7 +530,7 @@ def test_block_comment_continuation_with_nested_glob():
545
530
  result = _run_hook(
546
531
  "Write",
547
532
  {
548
- "file_path": _repository_file_path("src/cache.ts"),
533
+ "file_path": repository_file_path("src/cache.ts"),
549
534
  "content": content,
550
535
  },
551
536
  )
@@ -562,7 +547,7 @@ def test_detects_inline_after_url_on_same_line():
562
547
  result = _run_hook(
563
548
  "Write",
564
549
  {
565
- "file_path": _repository_file_path("src/fetch.ts"),
550
+ "file_path": repository_file_path("src/fetch.ts"),
566
551
  "content": 'const url = "https://api.com"; // no longer used',
567
552
  },
568
553
  )
@@ -579,7 +564,7 @@ def test_ignores_code_before_block_comment():
579
564
  result = _run_hook(
580
565
  "Write",
581
566
  {
582
- "file_path": _repository_file_path("src/cache.ts"),
567
+ "file_path": repository_file_path("src/cache.ts"),
583
568
  "content": "cache.replaces(old); /* Use fresh cache */",
584
569
  },
585
570
  )
@@ -595,7 +580,7 @@ def test_ignores_url_with_double_slash_in_python():
595
580
  result = _run_hook(
596
581
  "Write",
597
582
  {
598
- "file_path": _repository_file_path("src/main.py"),
583
+ "file_path": repository_file_path("src/main.py"),
599
584
  "content": 'url = "https://api.example.com/replaces/v1"',
600
585
  },
601
586
  )
@@ -610,7 +595,7 @@ def test_ignores_instead_of_in_code_string_with_inline_comment():
610
595
  result = _run_hook(
611
596
  "Write",
612
597
  {
613
- "file_path": _repository_file_path("src/main.py"),
598
+ "file_path": repository_file_path("src/main.py"),
614
599
  "content": "msg = 'instead of' # Use the default timeout",
615
600
  },
616
601
  )
@@ -626,7 +611,7 @@ def test_ignores_glob_pattern_with_star_in_python():
626
611
  result = _run_hook(
627
612
  "Write",
628
613
  {
629
- "file_path": _repository_file_path("src/main.py"),
614
+ "file_path": repository_file_path("src/main.py"),
630
615
  "content": content,
631
616
  },
632
617
  )
@@ -642,7 +627,7 @@ def test_ignores_comment_with_glob_pattern():
642
627
  result = _run_hook(
643
628
  "Write",
644
629
  {
645
- "file_path": _repository_file_path("src/main.py"),
630
+ "file_path": repository_file_path("src/main.py"),
646
631
  "content": content,
647
632
  },
648
633
  )
@@ -657,7 +642,7 @@ def test_single_line_block_comment_closes():
657
642
  result = _run_hook(
658
643
  "Write",
659
644
  {
660
- "file_path": _repository_file_path("src/cache.ts"),
645
+ "file_path": repository_file_path("src/cache.ts"),
661
646
  "content": content,
662
647
  },
663
648
  )
@@ -675,7 +660,7 @@ def test_block_comment_with_url_closes_correctly():
675
660
  result = _run_hook(
676
661
  "Write",
677
662
  {
678
- "file_path": _repository_file_path("src/cache.ts"),
663
+ "file_path": repository_file_path("src/cache.ts"),
679
664
  "content": content,
680
665
  },
681
666
  )
@@ -692,7 +677,7 @@ def test_same_line_block_comment_with_trailing_inline():
692
677
  result = _run_hook(
693
678
  "Write",
694
679
  {
695
- "file_path": _repository_file_path("src/fetch.ts"),
680
+ "file_path": repository_file_path("src/fetch.ts"),
696
681
  "content": content,
697
682
  },
698
683
  )
@@ -710,7 +695,7 @@ def test_multi_line_block_comment_close_with_trailing_inline():
710
695
  result = _run_hook(
711
696
  "Write",
712
697
  {
713
- "file_path": _repository_file_path("src/cache.ts"),
698
+ "file_path": repository_file_path("src/cache.ts"),
714
699
  "content": content,
715
700
  },
716
701
  )
@@ -727,7 +712,7 @@ def test_detects_earliest_inline_marker_in_php():
727
712
  result = _run_hook(
728
713
  "Write",
729
714
  {
730
- "file_path": _repository_file_path("src/index.php"),
715
+ "file_path": repository_file_path("src/index.php"),
731
716
  "content": 'echo $x; // previously used #tag',
732
717
  },
733
718
  )
@@ -741,7 +726,7 @@ def test_additional_context_contains_examples():
741
726
  result = _run_hook(
742
727
  "Write",
743
728
  {
744
- "file_path": _repository_file_path("src/main.py"),
729
+ "file_path": repository_file_path("src/main.py"),
745
730
  "content": VIOLATION_INSTEAD_OF_COMMENT,
746
731
  },
747
732
  )
@@ -789,7 +774,7 @@ def test_handles_non_string_tool_name():
789
774
  payload = json.dumps(
790
775
  {
791
776
  "tool_name": 123,
792
- "tool_input": {"file_path": _repository_file_path("src/main.py")},
777
+ "tool_input": {"file_path": repository_file_path("src/main.py")},
793
778
  "cwd": _REPOSITORY_ROOT,
794
779
  }
795
780
  )
@@ -816,7 +801,7 @@ def test_detects_used_to_in_python_module_docstring() -> None:
816
801
  result = _run_hook(
817
802
  "Write",
818
803
  {
819
- "file_path": _repository_file_path("tests/test_exports.py"),
804
+ "file_path": repository_file_path("tests/test_exports.py"),
820
805
  "content": written_source,
821
806
  },
822
807
  )
@@ -837,7 +822,7 @@ def test_detects_previously_in_python_function_docstring() -> None:
837
822
  result = _run_hook(
838
823
  "Write",
839
824
  {
840
- "file_path": _repository_file_path("src/configuration.py"),
825
+ "file_path": repository_file_path("src/configuration.py"),
841
826
  "content": written_source,
842
827
  },
843
828
  )
@@ -854,7 +839,7 @@ def test_ignores_transitional_phrase_in_regular_python_string() -> None:
854
839
  result = _run_hook(
855
840
  "Write",
856
841
  {
857
- "file_path": _repository_file_path("src/messages.py"),
842
+ "file_path": repository_file_path("src/messages.py"),
858
843
  "content": 'PADDING_NOTE = "used to pad with a flat sleep"\n',
859
844
  },
860
845
  )
@@ -873,7 +858,7 @@ def test_ignores_quoted_mention_inside_python_docstring() -> None:
873
858
  result = _run_hook(
874
859
  "Write",
875
860
  {
876
- "file_path": _repository_file_path("src/scanner.py"),
861
+ "file_path": repository_file_path("src/scanner.py"),
877
862
  "content": written_source,
878
863
  },
879
864
  )
@@ -889,7 +874,7 @@ def test_detects_docstring_at_start_of_unparseable_edit_fragment() -> None:
889
874
  result = _run_hook(
890
875
  "Edit",
891
876
  {
892
- "file_path": _repository_file_path("src/exports.py"),
877
+ "file_path": repository_file_path("src/exports.py"),
893
878
  "old_string": "placeholder",
894
879
  "new_string": fragment,
895
880
  },
@@ -911,7 +896,7 @@ def test_detects_docstring_after_def_header_in_unparseable_fragment() -> None:
911
896
  result = _run_hook(
912
897
  "Edit",
913
898
  {
914
- "file_path": _repository_file_path("src/reporting.py"),
899
+ "file_path": repository_file_path("src/reporting.py"),
915
900
  "old_string": "placeholder",
916
901
  "new_string": fragment,
917
902
  },
@@ -930,7 +915,7 @@ def test_ignores_assigned_triple_quoted_string_in_unparseable_fragment() -> None
930
915
  result = _run_hook(
931
916
  "Edit",
932
917
  {
933
- "file_path": _repository_file_path("src/fixtures.py"),
918
+ "file_path": repository_file_path("src/fixtures.py"),
934
919
  "old_string": "placeholder",
935
920
  "new_string": fragment,
936
921
  },
@@ -950,7 +935,7 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
950
935
  deny_payload = {
951
936
  "tool_name": "Write",
952
937
  "tool_input": {
953
- "file_path": _repository_file_path("src/main.py"),
938
+ "file_path": repository_file_path("src/main.py"),
954
939
  "content": VIOLATION_INSTEAD_OF_COMMENT,
955
940
  },
956
941
  "cwd": _REPOSITORY_ROOT,
@@ -1,4 +1,9 @@
1
- """Tests for tdd-enforcer hook (blocking behavior)."""
1
+ """Tests for tdd-enforcer hook (blocking behavior).
2
+
3
+ Includes the recorded-test-failure evidence path: a genuinely recorded
4
+ failure unlocks a production write, and never unlocks it for a test file
5
+ the failing command did not name.
6
+ """
2
7
 
3
8
  import importlib.util
4
9
  import io
@@ -1352,6 +1357,67 @@ def test_should_allow_repeat_production_write_within_window_without_further_test
1352
1357
  assert _decision_from(second_write) == "allow"
1353
1358
 
1354
1359
 
1360
+ def _seed_recorded_test_failure(
1361
+ test_file: Path, session_id: str, repository_root: Path, command: str, exit_status: int
1362
+ ) -> None:
1363
+ """Plant a failure record as if a PostToolUse hook had genuinely seen it fail."""
1364
+ content_hash_store.record_test_command_failure(
1365
+ [test_file], command, exit_status, session_id, str(repository_root)
1366
+ )
1367
+
1368
+
1369
+ def test_should_allow_production_write_when_recorded_test_failure_matches_content(
1370
+ tmp_path: Path,
1371
+ ) -> None:
1372
+ """RED evidence outranks a stale mtime: a genuinely recorded failure unlocks GREEN."""
1373
+ sandbox = _sandbox(tmp_path)
1374
+ production_file = sandbox / "orders.py"
1375
+ production_file.write_text("def fulfill(): pass\n")
1376
+ sibling_test = sandbox / "test_orders.py"
1377
+ sibling_test.write_text("def test_fulfill(): assert fulfill() == 1\n")
1378
+ stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
1379
+ os.utime(sibling_test, (stale_timestamp, stale_timestamp))
1380
+ session_id = "recorded-failure-session"
1381
+ _seed_recorded_test_failure(
1382
+ sibling_test, session_id, sandbox, "pytest test_orders.py", 1
1383
+ )
1384
+
1385
+ completed = _run_hook_with_payload(
1386
+ _make_write_payload_for_session(
1387
+ production_file, "def fulfill(): return 1\n", session_id, sandbox
1388
+ )
1389
+ )
1390
+
1391
+ assert _decision_from(completed) == "allow"
1392
+
1393
+
1394
+ def test_should_deny_production_write_when_recorded_failure_names_a_different_file(
1395
+ tmp_path: Path,
1396
+ ) -> None:
1397
+ """Anti-bypass: a failure recorded for one test file must not unlock a sibling's gate."""
1398
+ sandbox = _sandbox(tmp_path)
1399
+ production_file = sandbox / "orders.py"
1400
+ production_file.write_text("def fulfill(): pass\n")
1401
+ sibling_test = sandbox / "test_orders.py"
1402
+ sibling_test.write_text("def test_fulfill(): assert fulfill() == 1\n")
1403
+ stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
1404
+ os.utime(sibling_test, (stale_timestamp, stale_timestamp))
1405
+ unrelated_test = sandbox / "test_unrelated.py"
1406
+ unrelated_test.write_text("def test_other(): pass\n")
1407
+ session_id = "recorded-failure-unrelated-session"
1408
+ _seed_recorded_test_failure(
1409
+ unrelated_test, session_id, sandbox, "pytest test_unrelated.py", 1
1410
+ )
1411
+
1412
+ completed = _run_hook_with_payload(
1413
+ _make_write_payload_for_session(
1414
+ production_file, "def fulfill(): return 1\n", session_id, sandbox
1415
+ )
1416
+ )
1417
+
1418
+ assert _decision_from(completed) == "deny"
1419
+
1420
+
1355
1421
  def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_transaction(
1356
1422
  tmp_path: Path,
1357
1423
  ) -> None: