claude-dev-env 1.94.0 → 2.0.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 (280) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +41 -33
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/CLAUDE.md +10 -1
  42. package/bin/ever-shipped-skills.mjs +73 -0
  43. package/bin/expand_home_directory_tokens.mjs +1 -1
  44. package/bin/install.mjs +140 -7
  45. package/bin/install.prune.test.mjs +457 -0
  46. package/docs/CODE_RULES.md +1 -1
  47. package/hooks/advisory/refactor_guard.py +3 -4
  48. package/hooks/blocking/CLAUDE.md +7 -1
  49. package/hooks/blocking/block_main_commit.py +2 -2
  50. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  61. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  62. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  63. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  64. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  65. package/hooks/blocking/code_rules_enforcer.py +4 -0
  66. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  67. package/hooks/blocking/code_rules_optional_params.py +2 -2
  68. package/hooks/blocking/code_rules_shared.py +83 -1
  69. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  70. package/hooks/blocking/code_rules_test_layout.py +9 -3
  71. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  72. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  73. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  74. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  75. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  76. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  77. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  78. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  79. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  80. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  85. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  86. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  87. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  88. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  89. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  90. package/hooks/blocking/pii_payload_scan.py +138 -42
  91. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  92. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  93. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  94. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  95. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  96. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  97. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  98. package/hooks/blocking/plain_language_blocker.py +138 -4
  99. package/hooks/blocking/pr_description_command_parser.py +8 -4
  100. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  101. package/hooks/blocking/sensitive_file_protector.py +114 -48
  102. package/hooks/blocking/tdd_enforcer.py +97 -601
  103. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  104. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  105. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  106. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  107. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  108. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  109. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  110. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  111. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  112. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  113. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  114. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  115. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  116. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  117. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  118. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  119. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  120. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  121. package/hooks/blocking/test_code_rules_shared.py +181 -0
  122. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  123. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  124. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  125. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  126. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  127. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  128. package/hooks/blocking/tests/conftest.py +10 -0
  129. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  130. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  131. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  132. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  133. package/hooks/git-hooks/post_commit.py +3 -4
  134. package/hooks/hooks_constants/CLAUDE.md +4 -2
  135. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  136. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  137. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  138. package/hooks/hooks_constants/local_identity.py +59 -8
  139. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  140. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  141. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  142. package/hooks/hooks_constants/test_local_identity.py +105 -3
  143. package/hooks/pyproject.toml +52 -4
  144. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  145. package/hooks/validation/mypy_validator.py +2 -2
  146. package/hooks/validators/CLAUDE.md +1 -1
  147. package/hooks/validators/README.md +2 -0
  148. package/hooks/validators/health_check.py +1 -0
  149. package/hooks/validators/mypy_integration.py +2 -0
  150. package/hooks/validators/python_style_checks.py +114 -136
  151. package/hooks/validators/python_style_helpers.py +95 -0
  152. package/hooks/validators/ruff_integration.py +3 -0
  153. package/hooks/validators/test_python_style_checks.py +0 -164
  154. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  155. package/hooks/validators/test_python_style_fixes.py +251 -0
  156. package/hooks/validators/test_python_style_helpers.py +125 -0
  157. package/hooks/workflow/auto_formatter.py +5 -4
  158. package/package.json +1 -1
  159. package/rules/CLAUDE.md +1 -0
  160. package/rules/anti-corollary-tests.md +69 -0
  161. package/rules/bdd.md +1 -3
  162. package/rules/code-reviews.md +1 -1
  163. package/rules/gh-paginate.md +1 -1
  164. package/rules/plain-language.md +2 -0
  165. package/scripts/CLAUDE.md +4 -0
  166. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  167. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  168. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  169. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  170. package/scripts/grok_headless_runner.py +294 -0
  171. package/scripts/grok_worker_preflight.py +410 -0
  172. package/scripts/invoke_code_review.py +463 -0
  173. package/scripts/resolve_worker_spawn.py +619 -0
  174. package/scripts/spawn_grok_batch.py +672 -0
  175. package/scripts/test_grok_headless_runner.py +626 -0
  176. package/scripts/test_grok_worker_preflight.py +1054 -0
  177. package/scripts/test_invoke_code_review.py +672 -0
  178. package/scripts/test_resolve_worker_spawn.py +1014 -0
  179. package/scripts/test_spawn_grok_batch.py +1017 -0
  180. package/skills/CLAUDE.md +6 -3
  181. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  182. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  183. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  184. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  185. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  186. package/skills/autoconverge/CLAUDE.md +3 -3
  187. package/skills/autoconverge/SKILL.md +9 -3
  188. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  189. package/skills/autoconverge/reference/convergence.md +33 -11
  190. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  191. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  192. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  193. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  194. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  195. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  196. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  197. package/skills/autoconverge/workflow/converge.mjs +202 -18
  198. package/skills/bugteam/CLAUDE.md +2 -2
  199. package/skills/bugteam/CONSTRAINTS.md +3 -2
  200. package/skills/bugteam/PROMPTS.md +7 -6
  201. package/skills/bugteam/SKILL.md +18 -13
  202. package/skills/bugteam/reference/README.md +2 -3
  203. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  204. package/skills/bugteam/reference/design-rationale.md +1 -1
  205. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  206. package/skills/bugteam/reference/team-setup.md +8 -2
  207. package/skills/closeout/SKILL.md +153 -0
  208. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  209. package/skills/closeout/reference/issue-body-templates.md +108 -0
  210. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  211. package/skills/codex-review/CLAUDE.md +46 -0
  212. package/skills/codex-review/SKILL.md +181 -0
  213. package/skills/codex-review/reference/CLAUDE.md +15 -0
  214. package/skills/codex-review/reference/cli-contract.md +253 -0
  215. package/skills/codex-review/reference/loop-integration.md +118 -0
  216. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  217. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  218. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  219. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  220. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  221. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  222. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  223. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  224. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  225. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  226. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  227. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  228. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  229. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  230. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  231. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  232. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  233. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  234. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  235. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  236. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  237. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  238. package/skills/codex-review/test_skill_scaffold.py +192 -0
  239. package/skills/grok-spawn/CLAUDE.md +28 -0
  240. package/skills/grok-spawn/SKILL.md +226 -0
  241. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  242. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  243. package/skills/grokify/SKILL.md +9 -1
  244. package/skills/grokify/capability-claims.test.mjs +28 -0
  245. package/skills/grokify/evals/README.md +72 -0
  246. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  247. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  248. package/skills/orchestrator/SKILL.md +32 -22
  249. package/skills/orchestrator-refresh/SKILL.md +12 -8
  250. package/skills/pr-converge/CLAUDE.md +1 -1
  251. package/skills/pr-converge/SKILL.md +34 -13
  252. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  253. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  254. package/skills/pr-converge/reference/ground-rules.md +1 -1
  255. package/skills/pr-converge/reference/per-tick.md +130 -42
  256. package/skills/pr-converge/reference/state-schema.md +10 -0
  257. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  258. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  259. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  260. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  261. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  262. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  263. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  264. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  265. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  266. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  267. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  268. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  269. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  270. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  271. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  272. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  273. package/skills/pr-fix-protocol/SKILL.md +65 -0
  274. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  275. package/skills/reviewer-gates/SKILL.md +7 -5
  276. package/skills/skill-builder/references/skill-modularity.md +1 -1
  277. package/skills/team-advisor/SKILL.md +16 -12
  278. package/system-prompts/software-engineer.xml +7 -6
  279. package/hooks/validators/test_verify_paths.py +0 -32
  280. package/hooks/validators/verify_paths.py +0 -57
@@ -1,640 +1,136 @@
1
- #!/usr/bin/env python3
2
- """
3
- BDD Automate-phase gate (production code touch).
1
+ """BDD Automate-phase gate (production code touch).
4
2
 
5
- Blocks writes to production source files when no matching test exists
6
- or the matching test has not been modified within the configured
7
- freshness window. Enforces "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
3
+ Blocks a write to a production source file when no matching test was modified
4
+ within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
5
+ Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
6
+ into one PreToolUse gate and re-exports their surface for the test suite.
8
7
  """
9
- import ast
8
+
10
9
  import json
11
- import re
12
10
  import sys
13
- import time
14
- from collections import Counter
15
11
  from pathlib import Path
16
12
 
17
-
18
- _hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
19
- _blocking_directory_path_string = str(Path(__file__).resolve().parent)
20
- if _hooks_root_path_string not in sys.path:
21
- sys.path.insert(0, _hooks_root_path_string)
22
- if _blocking_directory_path_string not in sys.path:
23
- sys.path.insert(0, _blocking_directory_path_string)
24
-
25
- from code_rules_shared import is_ephemeral_script_path # noqa: E402
26
-
27
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
28
- from hooks_constants.messages import USER_FACING_TDD_NOTICE # noqa: E402
29
-
30
- PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
31
- SKIP_PATTERNS = {
32
- 'test_', '_test.', '.test.', 'tests/', '__tests__/',
33
- 'conftest', 'fixture', 'mock', 'stub'
34
- }
35
- SKIP_EXTENSIONS = {'.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.txt'}
36
- DOTCLAUDE_PATH_SEGMENTS = frozenset({".claude"})
37
-
38
-
39
- def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
40
- normalized_path = file_path_string.replace("\\", "/")
41
- for each_segment in normalized_path.split("/"):
42
- if each_segment and each_segment in DOTCLAUDE_PATH_SEGMENTS:
43
- return True
44
- return False
45
-
46
-
47
- def _freshness_seconds() -> int:
48
- return 600
49
-
50
-
51
- def _constants_only_allowed_node_types() -> tuple[type, ...]:
52
- return (
53
- ast.Import,
54
- ast.ImportFrom,
55
- ast.Assign,
56
- ast.AnnAssign,
13
+ try:
14
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
15
+ _blocking_directory = str(Path(__file__).resolve().parent)
16
+ for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
17
+ if each_bootstrap_directory not in sys.path:
18
+ sys.path.insert(0, each_bootstrap_directory)
19
+ from code_rules_shared import (
20
+ is_ephemeral_script_path,
21
+ is_under_session_scratchpad,
57
22
  )
23
+ from tdd_enforcer_parts import (
24
+ candidate_paths,
25
+ content_analysis,
26
+ decisions,
27
+ freshness,
28
+ git_tracking,
29
+ path_classification,
30
+ )
31
+ from tdd_enforcer_parts.config.tdd_enforcer_constants import PYTHON_SOURCE_EXTENSION
32
+ except ImportError as import_error:
33
+ raise ImportError(
34
+ "tdd_enforcer: cannot import its tdd_enforcer_parts submodules; "
35
+ "ensure the hooks directory is importable."
36
+ ) from import_error
37
+
38
+
39
+ def _is_session_scratchpad_write(file_path: str, input_data: dict) -> bool:
40
+ return is_under_session_scratchpad(file_path, input_data)
41
+
42
+
43
+ production_extensions = path_classification.production_extensions
44
+ skip_extensions = path_classification.skip_extensions
45
+ _is_inside_dotclaude_segment = path_classification._is_inside_dotclaude_segment
46
+ _directory_skip_components = path_classification._directory_skip_components
47
+ _matches_any_skip_pattern = path_classification._matches_any_skip_pattern
48
+ _extract_written_content = path_classification._extract_written_content
49
+ candidate_test_paths_for = candidate_paths.candidate_test_paths_for
50
+ _ancestor_tests_directories = candidate_paths._ancestor_tests_directories
51
+ _parent_walk_limit = candidate_paths._parent_walk_limit
52
+ _freshness_seconds = freshness._freshness_seconds
53
+ has_fresh_test = freshness.has_fresh_test
54
+ _read_candidate_text = freshness._read_candidate_text
55
+ _is_constants_only_python_content = content_analysis._is_constants_only_python_content
56
+ _is_post_edit_import_only = content_analysis._is_post_edit_import_only
57
+ _is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
58
+ is_absent_but_tracked = git_tracking.is_absent_but_tracked
59
+ build_deny_reason = decisions.build_deny_reason
60
+ emit_allow = decisions.emit_allow
61
+ emit_deny = decisions.emit_deny
62
+
63
+
64
+ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
65
+ tool_input = input_data.get("tool_input", {})
66
+ return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
58
67
 
59
68
 
60
- def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
61
- if not isinstance(module_level_node, ast.Expr):
62
- return False
63
- expression_value = module_level_node.value
64
- if not isinstance(expression_value, ast.Constant):
65
- return False
66
- return isinstance(expression_value.value, str)
67
-
68
-
69
- def _safe_constant_functions() -> frozenset[str]:
70
- """Unqualified function names treated as safe value constructors."""
71
- return frozenset({"Path", "frozenset"})
72
-
73
-
74
- def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
75
- """(module, attr) pairs treated as safe value constructors."""
76
- return frozenset({("re", "compile")})
77
-
78
-
79
- def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
80
- """Return True when rhs_node contains a function call outside the safe allowlist.
81
-
82
- Safe calls are value constructors (``Path(...)``, ``re.compile(...)``)
83
- that create objects without side effects. Any other call pattern is
84
- treated as unsafe import-time behavior.
85
- """
86
- safe_functions = _safe_constant_functions()
87
- safe_attribute_calls = _safe_constant_attribute_calls()
88
- for each_subnode in ast.walk(rhs_node):
89
- if isinstance(each_subnode, ast.Call):
90
- function_node = each_subnode.func
91
- if isinstance(function_node, ast.Name):
92
- if function_node.id not in safe_functions:
93
- return True
94
- elif isinstance(function_node, ast.Attribute):
95
- if isinstance(function_node.value, ast.Name):
96
- pair = (function_node.value.id, function_node.attr)
97
- if pair not in safe_attribute_calls:
98
- return True
99
- else:
100
- return True
101
- else:
102
- return True
103
- elif isinstance(
104
- each_subnode,
105
- (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda),
106
- ):
107
- return True
108
- return False
109
-
110
-
111
- def _is_constants_only_python_content(content: str) -> bool:
112
- if not content.strip():
113
- return False
114
- try:
115
- parsed_tree = ast.parse(content)
116
- except SyntaxError:
117
- return False
118
- if not parsed_tree.body:
119
- return False
120
- allowed_node_types = _constants_only_allowed_node_types()
121
- for each_top_level_node in parsed_tree.body:
122
- if isinstance(each_top_level_node, allowed_node_types):
123
- if isinstance(each_top_level_node, (ast.Assign, ast.AnnAssign)):
124
- rhs = each_top_level_node.value
125
- if rhs is not None and _rhs_has_unsafe_call(rhs):
126
- return False
127
- continue
128
- if _is_module_docstring_expression(each_top_level_node):
129
- continue
130
- return False
131
- return True
132
-
133
-
134
- def _apply_edit_to_content(
135
- existing_content: str, old_str: str, new_str: str, should_replace_all: bool
136
- ) -> str:
137
- """Apply an edit's replacement to content the way the Edit tool would.
138
-
139
- Args:
140
- existing_content: The text being edited.
141
- old_str: The substring the edit replaces.
142
- new_str: The replacement substring.
143
- should_replace_all: Replace every occurrence when True (matching the
144
- Edit tool's ``replace_all`` flag), otherwise only the first.
145
-
146
- Returns:
147
- The post-edit content.
148
- """
149
- if should_replace_all:
150
- return existing_content.replace(old_str, new_str)
151
- return existing_content.replace(old_str, new_str, 1)
152
-
153
-
154
- def _future_module_name() -> str:
155
- return "__future__"
156
-
157
-
158
- def _is_future_import(node: ast.stmt) -> bool:
159
- """Return whether a statement is a ``from __future__`` import.
160
-
161
- Args:
162
- node: A top-level module statement.
163
-
164
- Returns:
165
- True when the statement imports from ``__future__``, whose presence
166
- affects module-wide compilation semantics.
167
- """
168
- return isinstance(node, ast.ImportFrom) and node.module == _future_module_name()
169
-
170
-
171
- def _is_removable_import(node: ast.stmt) -> bool:
172
- """Return whether a statement is an import that removes or reorders cleanly.
173
-
174
- Args:
175
- node: A top-level module statement.
176
-
177
- Returns:
178
- True for plain ``import`` and ``from`` imports. ``from __future__``
179
- imports return False because their presence affects module-wide
180
- compilation semantics, so the gate treats them as behavior-bearing
181
- statements rather than removable imports; every non-import statement
182
- also returns False.
183
- """
184
- if isinstance(node, ast.Import):
69
+ def _is_silently_skipped(file_path: str, extension: str, path: Path) -> bool:
70
+ if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
185
71
  return True
186
- if isinstance(node, ast.ImportFrom):
187
- return not _is_future_import(node)
188
- return False
189
-
190
-
191
- def _future_import_signatures(content: str) -> list[str] | None:
192
- """Return the ``ast.dump`` signatures of a module's ``from __future__`` imports.
193
-
194
- Args:
195
- content: Python source text to parse.
196
-
197
- Returns:
198
- The future-import signatures in source order, or ``None`` when the
199
- content does not parse.
200
- """
201
- try:
202
- parsed_tree = ast.parse(content)
203
- except SyntaxError:
204
- return None
205
- return [ast.dump(each_node) for each_node in parsed_tree.body if _is_future_import(each_node)]
206
-
207
-
208
- def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
209
- """Check if post-edit content remains constants-only after Edit or MultiEdit.
210
-
211
- Both the existing content and the post-edit result must be constants-only
212
- to prevent edits on files with behavior from bypassing the TDD gate. Editing
213
- a ``from __future__`` import also fails this check, so a future-import edit
214
- on a constants-only file faces the gate rather than slipping through the
215
- constants exemption.
216
- """
217
- if not _is_constants_only_python_content(existing_content):
218
- return False
219
-
220
- if tool_name == "Edit":
221
- old_str = tool_input.get("old_string", "") or ""
222
- new_str = tool_input.get("new_string", "") or ""
223
- if not old_str:
224
- return False
225
- should_replace_all = bool(tool_input.get("replace_all", False))
226
- post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
227
- elif tool_name == "MultiEdit":
228
- all_edits = tool_input.get("edits", []) or []
229
- post_edit_content = existing_content
230
- for each_edit in all_edits:
231
- if not isinstance(each_edit, dict):
232
- return False
233
- each_old = each_edit.get("old_string", "") or ""
234
- each_new = each_edit.get("new_string", "") or ""
235
- if not each_old:
236
- return False
237
- should_replace_all = bool(each_edit.get("replace_all", False))
238
- post_edit_content = _apply_edit_to_content(
239
- post_edit_content, each_old, each_new, should_replace_all
240
- )
241
- else:
242
- return False
243
-
244
- if _future_import_signatures(existing_content) != _future_import_signatures(post_edit_content):
245
- return False
246
- return _is_constants_only_python_content(post_edit_content)
247
-
248
-
249
- def _top_level_signatures(content: str) -> tuple[list[str], list[str]] | None:
250
- """Split a module's top-level statements into removable-import and other signatures.
251
-
252
- Args:
253
- content: Python source text to parse.
254
-
255
- Returns:
256
- A pair ``(import_signatures, non_import_signatures)`` of ``ast.dump``
257
- strings in source order, or ``None`` when the content does not parse.
258
- Plain imports populate the first list; ``from __future__`` imports and
259
- every non-import statement populate the second, so editing a future
260
- import reads as a behavior edit rather than a removable-import edit.
261
- Signatures omit line and column attributes, so statements that only
262
- shift position compare equal.
263
- """
264
- try:
265
- parsed_tree = ast.parse(content)
266
- except SyntaxError:
267
- return None
268
- import_signatures: list[str] = []
269
- non_import_signatures: list[str] = []
270
- for each_node in parsed_tree.body:
271
- if _is_removable_import(each_node):
272
- import_signatures.append(ast.dump(each_node))
273
- else:
274
- non_import_signatures.append(ast.dump(each_node))
275
- return import_signatures, non_import_signatures
276
-
277
-
278
- def _is_post_edit_import_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
279
- """Check whether an Edit or MultiEdit only removes or reorders imports.
280
-
281
- The top-level statements are split into imports and the rest, before and
282
- after applying the edit. The edit is exempt only when the non-import
283
- statements are unchanged and every post-edit import statement already
284
- appears among the pre-edit imports, so removing or reordering imports is
285
- exempt while adding, swapping, or retargeting an import stays gated: those
286
- can change behavior through a new symbol in scope, a different
287
- implementation bound to the same name, or `from __future__` semantics. An
288
- edit that leaves the parsed module identical (a comment- or whitespace-only
289
- change) also stays gated. Reading the resulting file rather than the edit
290
- fragments keeps the exemption from firing on import text inside a string
291
- literal, and lets it fire on edits whose old string carries surrounding
292
- context for uniqueness.
293
-
294
- Args:
295
- existing_content: Current text of the file under edit.
296
- tool_name: The intercepted tool (Edit or MultiEdit).
297
- tool_input: The intercepted tool's input payload.
298
-
299
- Returns:
300
- True when the edit leaves the non-import statements unchanged and the
301
- post-edit imports are a reordering or removal of the pre-edit imports.
302
- """
303
- existing_signatures = _top_level_signatures(existing_content)
304
- if existing_signatures is None:
305
- return False
72
+ if extension in skip_extensions():
73
+ return True
74
+ if extension not in production_extensions():
75
+ return True
76
+ path_with_forward_slashes = str(path).lower().replace("\\", "/")
77
+ return _matches_any_skip_pattern(path.name.lower(), path_with_forward_slashes)
306
78
 
307
- if tool_name == "Edit":
308
- old_str = tool_input.get("old_string", "") or ""
309
- new_str = tool_input.get("new_string", "") or ""
310
- if not old_str:
311
- return False
312
- should_replace_all = bool(tool_input.get("replace_all", False))
313
- post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
314
- elif tool_name == "MultiEdit":
315
- all_edits = tool_input.get("edits", []) or []
316
- if not all_edits:
317
- return False
318
- post_edit_content = existing_content
319
- for each_edit in all_edits:
320
- if not isinstance(each_edit, dict):
321
- return False
322
- each_old = each_edit.get("old_string", "") or ""
323
- each_new = each_edit.get("new_string", "") or ""
324
- if not each_old:
325
- return False
326
- should_replace_all = bool(each_edit.get("replace_all", False))
327
- post_edit_content = _apply_edit_to_content(
328
- post_edit_content, each_old, each_new, should_replace_all
329
- )
330
- else:
331
- return False
332
79
 
333
- post_edit_signatures = _top_level_signatures(post_edit_content)
334
- if post_edit_signatures is None:
80
+ def _edit_is_constants_or_import_only(
81
+ tool_name: str, tool_input: dict, path: Path, extension: str
82
+ ) -> bool:
83
+ if (
84
+ tool_name not in ("Edit", "MultiEdit")
85
+ or extension != PYTHON_SOURCE_EXTENSION
86
+ or not path.exists()
87
+ ):
335
88
  return False
336
- existing_imports, existing_rest = existing_signatures
337
- post_imports, post_rest = post_edit_signatures
338
- if post_rest != existing_rest or post_imports == existing_imports:
89
+ existing_content = _read_candidate_text(path)
90
+ if existing_content is None:
339
91
  return False
340
- return Counter(post_imports) <= Counter(existing_imports)
341
-
342
-
343
- def _tests_directory_name() -> str:
344
- return "tests"
345
-
346
-
347
- def _parent_walk_limit() -> int:
348
- return 10
349
-
350
-
351
- def _repo_boundary_sentinels() -> frozenset[str]:
352
- return frozenset({".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"})
353
-
354
-
355
- def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
356
- return (
357
- re.compile(r"\bdef\s+test_"),
358
- re.compile(r"\b(?:it|test|describe)\s*\("),
359
- )
360
-
361
-
362
- def _directory_skip_components() -> frozenset[str]:
363
- return frozenset({
364
- "conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
365
- })
366
-
367
-
368
- def _is_repo_boundary(candidate_directory: Path) -> bool:
369
- for each_sentinel in _repo_boundary_sentinels():
370
- if (candidate_directory / each_sentinel).exists():
371
- return True
372
- return False
373
-
374
-
375
- def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
376
- """Collect each ancestor's sibling tests directory up to the repo boundary.
377
-
378
- Args:
379
- start_directory: Directory of the production file under edit.
380
-
381
- Returns:
382
- Ordered (ancestor, tests_directory) pairs; nearer ancestors first.
383
- """
384
- all_pairs: list[tuple[Path, Path]] = []
385
- current_directory = start_directory
386
- for _ in range(_parent_walk_limit()):
387
- sibling_tests = current_directory / _tests_directory_name()
388
- if sibling_tests.is_dir():
389
- all_pairs.append((current_directory, sibling_tests))
390
- if _is_repo_boundary(current_directory):
391
- break
392
- if current_directory.parent == current_directory:
393
- break
394
- current_directory = current_directory.parent
395
- return all_pairs
396
-
397
-
398
- def _split_module_stem_prefix() -> str:
399
- return "code_rules_"
400
-
401
-
402
- def _split_test_family_glob() -> str:
403
- return "test_code_rules_enforcer_*.py"
404
-
405
-
406
- def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
407
- if not stem.startswith(_split_module_stem_prefix()):
408
- return []
409
- return sorted(directory.glob(_split_test_family_glob()))
410
-
411
-
412
- def candidate_test_paths_for(production_path: Path) -> list[Path]:
413
- """Return the test files whose freshness can satisfy the gate for a production file.
414
-
415
- Every ancestor ``tests`` directory contributes a flat candidate
416
- (``tests/test_<stem>.py``) and package-mirroring nested candidates
417
- (``tests/<subpackage path>/test_<stem>.py``), so repos that keep tests
418
- either beside the package or in a category tree both resolve.
419
-
420
- For ``code_rules_*`` Python modules the candidate list is extended with the
421
- sibling split test family (``test_code_rules_enforcer_*.py``), because that
422
- family collectively covers the split check modules; a fresh edit to any
423
- family file satisfies the RED step for editing one of those modules. The
424
- glob is directory-local, so ``code_rules_*`` files elsewhere gain no extra
425
- candidates. Plain stem-derived candidates always come first.
426
-
427
- Args:
428
- production_path: The production source file being written or edited.
429
-
430
- Returns:
431
- Ordered candidate test paths; stem-derived siblings precede any
432
- split-family additions.
433
- """
434
- directory = production_path.parent
435
- stem = production_path.stem
436
- extension = production_path.suffix.lower()
437
- all_candidates: list[Path] = []
438
-
439
- if extension == ".py":
440
- all_candidates.append(directory / f"test_{stem}.py")
441
- all_candidates.append(directory / f"{stem}_test.py")
442
- for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
443
- all_candidates.append(each_tests_directory / f"test_{stem}.py")
444
- nested_directory = each_tests_directory
445
- for each_relative_part in directory.relative_to(each_ancestor).parts:
446
- nested_directory = nested_directory / each_relative_part
447
- all_candidates.append(nested_directory / f"test_{stem}.py")
448
- all_candidates.extend(_split_family_candidates(directory, stem))
449
- return all_candidates
450
-
451
- if extension in {".tsx", ".ts", ".jsx", ".js"}:
452
- all_candidates.append(directory / f"{stem}.test{extension}")
453
- all_candidates.append(directory / f"{stem}.spec{extension}")
454
- return all_candidates
455
-
456
- return all_candidates
457
-
458
-
459
- def _test_file_encoding() -> str:
460
- return "utf-8"
461
-
462
-
463
- def _safe_mtime(candidate_path: Path) -> float | None:
464
- try:
465
- return candidate_path.stat().st_mtime
466
- except (FileNotFoundError, OSError):
467
- return None
468
-
469
-
470
- def _read_candidate_text(candidate_path: Path) -> str | None:
471
- try:
472
- with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
473
- return each_file.read()
474
- except (FileNotFoundError, OSError):
475
- return None
476
-
477
-
478
- def _contains_test_evidence(candidate_path: Path) -> bool:
479
- test_file_content = _read_candidate_text(candidate_path)
480
- if test_file_content is None:
481
- return False
482
- for each_pattern in _test_function_patterns():
483
- if each_pattern.search(test_file_content):
484
- return True
485
- return False
92
+ if _is_post_edit_import_only(existing_content, tool_name, tool_input):
93
+ return True
94
+ return _is_post_edit_constants_only(existing_content, tool_name, tool_input)
486
95
 
487
96
 
488
- def has_fresh_test(
489
- all_candidates: list[Path],
490
- freshness_seconds: int,
97
+ def _write_is_exempt(
98
+ tool_name: str, tool_input: dict, path: Path, extension: str
491
99
  ) -> bool:
492
- current_time = time.time()
493
- for each_candidate in all_candidates:
494
- candidate_mtime = _safe_mtime(each_candidate)
495
- if candidate_mtime is None:
496
- continue
497
- age_seconds = current_time - candidate_mtime
498
- if age_seconds > freshness_seconds:
499
- continue
500
- if not _contains_test_evidence(each_candidate):
501
- continue
100
+ if is_absent_but_tracked(path):
502
101
  return True
503
- return False
504
-
505
-
506
- def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
507
- candidate_lines = "\n".join(f" - {each_path}" for each_path in all_candidates)
508
- hook_source_path = Path(__file__).resolve()
509
- return (
510
- f"[TDD] Blocking write to production file: {production_path}\n"
511
- f"No matching test file exists, or it has not been modified within the last "
512
- f"{_freshness_seconds()} seconds.\n"
513
- f"Expected one of:\n{candidate_lines}\n"
514
- f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
515
- f"If this file legitimately does not need a test (for example, a module containing only "
516
- f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
517
- f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
518
- f"automatically. Do not add escape-hatch markers to production files."
519
- )
520
-
521
-
522
- def emit_allow() -> None:
523
- allow_payload = {
524
- "hookSpecificOutput": {
525
- "hookEventName": "PreToolUse",
526
- "permissionDecision": "allow",
527
- }
528
- }
529
- print(json.dumps(allow_payload))
530
-
531
-
532
- def emit_deny(reason: str) -> None:
533
- deny_payload = {
534
- "hookSpecificOutput": {
535
- "hookEventName": "PreToolUse",
536
- "permissionDecision": "deny",
537
- "permissionDecisionReason": reason,
538
- },
539
- "suppressOutput": True,
540
- "systemMessage": USER_FACING_TDD_NOTICE,
541
- }
542
- log_hook_block(
543
- calling_hook_name="tdd_enforcer.py",
544
- hook_event="PreToolUse",
545
- block_reason=reason,
546
- )
547
- print(json.dumps(deny_payload))
548
-
549
-
550
- def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
551
- path_components_lower = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
552
- directory_components = path_components_lower[:-1]
553
- skip_directory_components = _directory_skip_components()
554
- for each_directory_component in directory_components:
555
- if each_directory_component in skip_directory_components:
556
- return True
557
- for each_pattern in SKIP_PATTERNS:
558
- if each_pattern.endswith("/"):
559
- if each_pattern in path_with_forward_slashes:
560
- return True
561
- continue
562
- if each_pattern in name_lower:
563
- return True
564
- return False
565
-
566
-
567
- def _extract_written_content(tool_name: str, tool_input: dict) -> str:
568
- if tool_name == "Write":
569
- return tool_input.get("content", "") or ""
570
- if tool_name == "Edit":
571
- return tool_input.get("new_string", "") or ""
572
- if tool_name == "MultiEdit":
573
- all_edits = tool_input.get("edits", []) or []
574
- joined_new_strings: list[str] = []
575
- for each_edit in all_edits:
576
- if isinstance(each_edit, dict):
577
- joined_new_strings.append(each_edit.get("new_string", "") or "")
578
- return "\n".join(joined_new_strings)
579
- return ""
102
+ written_content = _extract_written_content(tool_name, tool_input)
103
+ if (
104
+ tool_name == "Write"
105
+ and extension == PYTHON_SOURCE_EXTENSION
106
+ and _is_constants_only_python_content(written_content)
107
+ ):
108
+ return True
109
+ return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
580
110
 
581
111
 
582
112
  def main() -> None:
113
+ """Run the TDD gate over one PreToolUse Write, Edit, or MultiEdit payload."""
583
114
  try:
584
115
  input_data = json.load(sys.stdin)
585
116
  except json.JSONDecodeError:
586
117
  sys.exit(0)
587
-
588
- tool_name = input_data.get("tool_name", "")
589
- tool_input = input_data.get("tool_input", {})
590
- file_path = tool_input.get("file_path", "")
591
-
118
+ tool_name, tool_input, file_path = _resolve_payload(input_data)
592
119
  if not file_path:
593
120
  sys.exit(0)
594
-
595
- if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
121
+ if _is_session_scratchpad_write(file_path, input_data):
596
122
  sys.exit(0)
597
-
598
123
  path = Path(file_path)
599
- ext = path.suffix.lower()
600
-
601
- # Skip config/docs
602
- if ext in SKIP_EXTENSIONS:
124
+ extension = path.suffix.lower()
125
+ if _is_silently_skipped(file_path, extension, path):
603
126
  sys.exit(0)
604
-
605
- # Skip non-production code files
606
- if ext not in PRODUCTION_EXTENSIONS:
607
- sys.exit(0)
608
-
609
- # Skip test files
610
- name_lower = path.name.lower()
611
- path_str = str(path).lower().replace("\\", "/")
612
- if _matches_any_skip_pattern(name_lower, path_str):
613
- sys.exit(0)
614
-
615
- # Block production code - require confirmation
616
- # Exempt constants-only content for Write (full content provided)
617
- written_content = _extract_written_content(tool_name, tool_input)
618
- if tool_name == "Write" and ext == ".py" and _is_constants_only_python_content(written_content):
127
+ if _write_is_exempt(tool_name, tool_input, path, extension):
619
128
  emit_allow()
620
129
  sys.exit(0)
621
-
622
- # Exempt Edit/MultiEdit on constants-only files when post-edit content remains constants-only
623
- if tool_name in ("Edit", "MultiEdit") and ext == ".py" and path.exists():
624
- existing_content = _read_candidate_text(path)
625
- if existing_content is not None:
626
- if _is_post_edit_import_only(existing_content, tool_name, tool_input):
627
- emit_allow()
628
- sys.exit(0)
629
- if _is_post_edit_constants_only(existing_content, tool_name, tool_input):
630
- emit_allow()
631
- sys.exit(0)
632
-
633
130
  all_candidates = candidate_test_paths_for(path)
634
131
  if has_fresh_test(all_candidates, _freshness_seconds()):
635
132
  emit_allow()
636
133
  sys.exit(0)
637
-
638
134
  emit_deny(build_deny_reason(path, all_candidates))
639
135
  sys.exit(0)
640
136