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