claude-dev-env 1.95.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) 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/test_verifier_verdict_minter.py +55 -158
  117. package/hooks/blocking/tests/conftest.py +10 -0
  118. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  119. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  120. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  121. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  122. package/hooks/git-hooks/post_commit.py +3 -4
  123. package/hooks/hooks_constants/CLAUDE.md +2 -2
  124. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  125. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  126. package/hooks/hooks_constants/local_identity.py +59 -8
  127. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  128. package/hooks/hooks_constants/test_local_identity.py +105 -3
  129. package/hooks/pyproject.toml +13 -36
  130. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  131. package/hooks/validation/mypy_validator.py +2 -2
  132. package/hooks/validators/health_check.py +1 -0
  133. package/hooks/validators/mypy_integration.py +2 -0
  134. package/hooks/validators/ruff_integration.py +3 -0
  135. package/hooks/workflow/auto_formatter.py +5 -4
  136. package/package.json +1 -1
  137. package/scripts/CLAUDE.md +4 -0
  138. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  139. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  140. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  141. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  142. package/scripts/grok_headless_runner.py +294 -0
  143. package/scripts/grok_worker_preflight.py +410 -0
  144. package/scripts/invoke_code_review.py +463 -0
  145. package/scripts/resolve_worker_spawn.py +619 -0
  146. package/scripts/spawn_grok_batch.py +672 -0
  147. package/scripts/test_grok_headless_runner.py +626 -0
  148. package/scripts/test_grok_worker_preflight.py +1054 -0
  149. package/scripts/test_invoke_code_review.py +672 -0
  150. package/scripts/test_resolve_worker_spawn.py +1014 -0
  151. package/scripts/test_spawn_grok_batch.py +1017 -0
  152. package/skills/CLAUDE.md +5 -3
  153. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  154. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  155. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  157. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  158. package/skills/autoconverge/CLAUDE.md +3 -3
  159. package/skills/autoconverge/SKILL.md +9 -3
  160. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  161. package/skills/autoconverge/reference/convergence.md +33 -11
  162. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  163. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  164. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  165. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  166. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  167. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  168. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  169. package/skills/autoconverge/workflow/converge.mjs +200 -16
  170. package/skills/bugteam/CLAUDE.md +2 -2
  171. package/skills/bugteam/CONSTRAINTS.md +3 -2
  172. package/skills/bugteam/PROMPTS.md +7 -6
  173. package/skills/bugteam/SKILL.md +18 -13
  174. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  175. package/skills/bugteam/reference/design-rationale.md +1 -1
  176. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  177. package/skills/bugteam/reference/team-setup.md +8 -2
  178. package/skills/codex-review/CLAUDE.md +46 -0
  179. package/skills/codex-review/SKILL.md +181 -0
  180. package/skills/codex-review/reference/CLAUDE.md +15 -0
  181. package/skills/codex-review/reference/cli-contract.md +253 -0
  182. package/skills/codex-review/reference/loop-integration.md +118 -0
  183. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  189. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  190. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  191. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  193. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  194. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  195. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  196. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  197. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  199. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  200. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  201. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  202. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  203. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  204. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  205. package/skills/codex-review/test_skill_scaffold.py +192 -0
  206. package/skills/grok-spawn/CLAUDE.md +28 -0
  207. package/skills/grok-spawn/SKILL.md +226 -0
  208. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  209. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  210. package/skills/grokify/SKILL.md +9 -1
  211. package/skills/grokify/capability-claims.test.mjs +28 -0
  212. package/skills/grokify/evals/README.md +72 -0
  213. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  214. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  215. package/skills/orchestrator/SKILL.md +42 -29
  216. package/skills/orchestrator-refresh/SKILL.md +17 -9
  217. package/skills/pr-converge/SKILL.md +34 -13
  218. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  219. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  220. package/skills/pr-converge/reference/ground-rules.md +1 -1
  221. package/skills/pr-converge/reference/per-tick.md +130 -42
  222. package/skills/pr-converge/reference/state-schema.md +10 -0
  223. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  224. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  225. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  226. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  227. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  228. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  229. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  230. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  231. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  232. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  234. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  235. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  237. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  238. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  239. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  240. package/skills/reviewer-gates/SKILL.md +7 -5
  241. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,30 @@
1
+ # tdd_enforcer_parts
2
+
3
+ The concern modules `tdd_enforcer.py` wires together to run the TDD gate. Each
4
+ module owns one concern; the entry hook imports them and re-exports their
5
+ surface for the test suite.
6
+
7
+ ## Modules
8
+
9
+ | File | Purpose |
10
+ |---|---|
11
+ | `path_classification.py` | Classifies a write target (docs, tests, `.claude` trees) the gate skips, and extracts the written text from a Write, Edit, or MultiEdit payload |
12
+ | `content_analysis.py` | Decides whether a payload is constants-only, and whether an Edit or MultiEdit merely removes or reorders imports |
13
+ | `candidate_paths.py` | Resolves the candidate test files whose freshness can satisfy the gate for a production file |
14
+ | `freshness.py` | Checks whether a candidate test was modified within the window and holds a real test function |
15
+ | `git_tracking.py` | Detects a write that restores a git-tracked file absent on disk, so a remove-then-Write rewrite is exempt from the gate |
16
+ | `decisions.py` | Builds the deny reason and writes the allow or deny decision JSON to stdout |
17
+ | `__init__.py` | Package marker |
18
+
19
+ ## Subdirectories
20
+
21
+ | Entry | Description |
22
+ |---|---|
23
+ | `config/` | The freshness window, the ancestor-walk limit, the git-tracking command tokens, the source-file extension sets, and the join separator (`tdd_enforcer_constants.py`) |
24
+ | `tests/` | pytest suite with one test module per module above |
25
+
26
+ ## Running tests
27
+
28
+ ```bash
29
+ python -m pytest packages/claude-dev-env/hooks/blocking/tdd_enforcer_parts/tests/
30
+ ```
@@ -0,0 +1 @@
1
+ """Decomposed concern modules the ``tdd_enforcer`` entry hook wires together."""
@@ -0,0 +1,142 @@
1
+ """Resolve the test files whose freshness can satisfy the gate for a module.
2
+
3
+ Given a production file, builds every path a matching test could live at: the
4
+ flat sibling ``test_<stem>.py``, the package-mirroring candidates under an
5
+ ancestor ``tests`` directory, and the split test family for ``code_rules_*``
6
+ modules.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from tdd_enforcer_parts.config.tdd_enforcer_constants import (
12
+ ALL_JAVASCRIPT_TEST_EXTENSIONS,
13
+ ALL_REPO_BOUNDARY_SENTINELS,
14
+ PARENT_WALK_LIMIT,
15
+ PYTHON_SOURCE_EXTENSION,
16
+ )
17
+
18
+
19
+ def _tests_directory_name() -> str:
20
+ return "tests"
21
+
22
+
23
+ def _parent_walk_limit() -> int:
24
+ return PARENT_WALK_LIMIT
25
+
26
+
27
+ def _repo_boundary_sentinels() -> frozenset[str]:
28
+ return ALL_REPO_BOUNDARY_SENTINELS
29
+
30
+
31
+ def _split_module_stem_prefix() -> str:
32
+ return "code_rules_"
33
+
34
+
35
+ def _split_test_family_glob() -> str:
36
+ return "test_code_rules_enforcer_*.py"
37
+
38
+
39
+ def _javascript_test_extensions() -> frozenset[str]:
40
+ return ALL_JAVASCRIPT_TEST_EXTENSIONS
41
+
42
+
43
+ def _is_repo_boundary(candidate_directory: Path) -> bool:
44
+ for each_sentinel in _repo_boundary_sentinels():
45
+ if (candidate_directory / each_sentinel).exists():
46
+ return True
47
+ return False
48
+
49
+
50
+ def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
51
+ """Collect each ancestor's sibling ``tests`` directory up to the repo root.
52
+
53
+ Args:
54
+ start_directory: Directory of the production file under edit.
55
+
56
+ Returns:
57
+ Ordered ``(ancestor, tests_directory)`` pairs, nearer ancestors first.
58
+ """
59
+ all_pairs: list[tuple[Path, Path]] = []
60
+ current_directory = start_directory
61
+ remaining_steps = _parent_walk_limit()
62
+ while remaining_steps > 0:
63
+ remaining_steps -= 1
64
+ sibling_tests = current_directory / _tests_directory_name()
65
+ if sibling_tests.is_dir():
66
+ all_pairs.append((current_directory, sibling_tests))
67
+ if _is_repo_boundary(current_directory):
68
+ break
69
+ if current_directory.parent == current_directory:
70
+ break
71
+ current_directory = current_directory.parent
72
+ return all_pairs
73
+
74
+
75
+ def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
76
+ if not stem.startswith(_split_module_stem_prefix()):
77
+ return []
78
+ return sorted(directory.glob(_split_test_family_glob()))
79
+
80
+
81
+ def _flat_stem_candidates(directory: Path, stem: str) -> list[Path]:
82
+ return [directory / f"test_{stem}.py", directory / f"{stem}_test.py"]
83
+
84
+
85
+ def _mirrored_nested_candidates(
86
+ directory: Path, ancestor: Path, tests_directory: Path, stem: str
87
+ ) -> list[Path]:
88
+ all_candidates: list[Path] = []
89
+ nested_directory = tests_directory
90
+ for each_relative_part in directory.relative_to(ancestor).parts:
91
+ nested_directory = nested_directory / each_relative_part
92
+ all_candidates.append(nested_directory / f"test_{stem}.py")
93
+ return all_candidates
94
+
95
+
96
+ def _nested_tests_candidates(directory: Path, stem: str) -> list[Path]:
97
+ all_candidates: list[Path] = []
98
+ for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
99
+ all_candidates.append(each_tests_directory / f"test_{stem}.py")
100
+ all_candidates.extend(
101
+ _mirrored_nested_candidates(directory, each_ancestor, each_tests_directory, stem)
102
+ )
103
+ return all_candidates
104
+
105
+
106
+ def _python_candidate_test_paths(directory: Path, stem: str) -> list[Path]:
107
+ all_candidates = _flat_stem_candidates(directory, stem)
108
+ all_candidates.extend(_nested_tests_candidates(directory, stem))
109
+ all_candidates.extend(_split_family_candidates(directory, stem))
110
+ return all_candidates
111
+
112
+
113
+ def _javascript_candidate_test_paths(directory: Path, stem: str, extension: str) -> list[Path]:
114
+ return [directory / f"{stem}.test{extension}", directory / f"{stem}.spec{extension}"]
115
+
116
+
117
+ def candidate_test_paths_for(production_path: Path) -> list[Path]:
118
+ """Return the test files that can satisfy the gate for a production file.
119
+
120
+ ::
121
+
122
+ pkg/orders.py -> pkg/test_orders.py, pkg/orders_test.py,
123
+ pkg/tests/test_orders.py (and package mirrors)
124
+
125
+ ``code_rules_*`` Python modules also gain the split test family. Plain
126
+ stem-derived candidates always come first.
127
+
128
+ Args:
129
+ production_path: The production source file being written or edited.
130
+
131
+ Returns:
132
+ Ordered candidate test paths; stem-derived siblings precede any
133
+ split-family additions.
134
+ """
135
+ directory = production_path.parent
136
+ stem = production_path.stem
137
+ extension = production_path.suffix.lower()
138
+ if extension == PYTHON_SOURCE_EXTENSION:
139
+ return _python_candidate_test_paths(directory, stem)
140
+ if extension in _javascript_test_extensions():
141
+ return _javascript_candidate_test_paths(directory, stem, extension)
142
+ return []
@@ -0,0 +1 @@
1
+ """Configuration home for the TDD-enforcer parts constants."""
@@ -0,0 +1,32 @@
1
+ """Constants for the TDD-enforcer hook and its decomposed parts modules.
2
+
3
+ Centralizes the freshness window, the ancestor-walk limit, the git-tracking
4
+ command tokens, the source-file extension sets, and the join separator.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ FRESHNESS_WINDOW_SECONDS: int = 600
10
+ PARENT_WALK_LIMIT: int = 10
11
+ GIT_LS_FILES_TIMEOUT_SECONDS: int = 10
12
+ GIT_EXECUTABLE_NAME: str = "git"
13
+ GIT_LS_FILES_SUBCOMMAND: str = "ls-files"
14
+ GIT_PATHSPEC_SEPARATOR: str = "--"
15
+ PYTHON_SOURCE_EXTENSION: str = ".py"
16
+ ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
17
+ NEWLINE_JOIN_SEPARATOR: str = "\n"
18
+ ALL_PRODUCTION_EXTENSIONS: frozenset[str] = frozenset({".py", ".ts", ".tsx", ".js", ".jsx"})
19
+ ALL_SKIP_EXTENSIONS: frozenset[str] = frozenset(
20
+ {".md", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".txt"}
21
+ )
22
+ ALL_SKIP_NAME_PATTERNS: frozenset[str] = frozenset(
23
+ {"test_", "_test.", ".test.", "tests/", "__tests__/", "conftest", "fixture", "mock", "stub"}
24
+ )
25
+ ALL_DIRECTORY_SKIP_COMPONENTS: frozenset[str] = frozenset(
26
+ {"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs"}
27
+ )
28
+ ALL_DOTCLAUDE_PATH_SEGMENTS: frozenset[str] = frozenset({".claude"})
29
+ ALL_REPO_BOUNDARY_SENTINELS: frozenset[str] = frozenset(
30
+ {".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"}
31
+ )
32
+ ALL_JAVASCRIPT_TEST_EXTENSIONS: frozenset[str] = frozenset({".tsx", ".ts", ".jsx", ".js"})
@@ -0,0 +1,268 @@
1
+ """Content analysis for the TDD-enforcer constants and import-only exemptions.
2
+
3
+ Answers two questions about a payload: is the written module only module-level
4
+ constants (no behavior to test), and does an Edit or MultiEdit merely remove or
5
+ reorder imports on a file that already has no behavior worth a fresh test.
6
+ """
7
+
8
+ import ast
9
+ from collections import Counter
10
+
11
+
12
+ def _constants_only_allowed_node_types() -> tuple[type, ...]:
13
+ return (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign)
14
+
15
+
16
+ def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
17
+ if not isinstance(module_level_node, ast.Expr):
18
+ return False
19
+ expression_value = module_level_node.value
20
+ if not isinstance(expression_value, ast.Constant):
21
+ return False
22
+ return isinstance(expression_value.value, str)
23
+
24
+
25
+ def _safe_constant_functions() -> frozenset[str]:
26
+ return frozenset({"Path", "frozenset"})
27
+
28
+
29
+ def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
30
+ return frozenset({("re", "compile")})
31
+
32
+
33
+ def _node_is_unsafe_construct(subnode: ast.AST) -> bool:
34
+ return isinstance(
35
+ subnode,
36
+ (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda),
37
+ )
38
+
39
+
40
+ def _attribute_call_is_unsafe(function_node: ast.Attribute) -> bool:
41
+ if not isinstance(function_node.value, ast.Name):
42
+ return True
43
+ pair = (function_node.value.id, function_node.attr)
44
+ return pair not in _safe_constant_attribute_calls()
45
+
46
+
47
+ def _call_is_unsafe(call_node: ast.Call) -> bool:
48
+ function_node = call_node.func
49
+ if isinstance(function_node, ast.Name):
50
+ return function_node.id not in _safe_constant_functions()
51
+ if isinstance(function_node, ast.Attribute):
52
+ return _attribute_call_is_unsafe(function_node)
53
+ return True
54
+
55
+
56
+ def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
57
+ """Return True when a constant's value contains a non-allowlisted call.
58
+
59
+ Safe calls are value constructors (``Path(...)``, ``re.compile(...)``) that
60
+ build objects without side effects; any other call or a comprehension reads
61
+ as import-time behavior.
62
+
63
+ Args:
64
+ rhs_node: The assignment's right-hand-side expression node.
65
+
66
+ Returns:
67
+ True when an unsafe call or comprehension appears anywhere within it.
68
+ """
69
+ for each_subnode in ast.walk(rhs_node):
70
+ if isinstance(each_subnode, ast.Call) and _call_is_unsafe(each_subnode):
71
+ return True
72
+ if _node_is_unsafe_construct(each_subnode):
73
+ return True
74
+ return False
75
+
76
+
77
+ def _assignment_rhs_is_safe(node: ast.stmt) -> bool:
78
+ right_hand_side = getattr(node, "value", None)
79
+ if right_hand_side is None:
80
+ return True
81
+ return not _rhs_has_unsafe_call(right_hand_side)
82
+
83
+
84
+ def _top_level_node_is_constant(node: ast.stmt) -> bool:
85
+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
86
+ return _assignment_rhs_is_safe(node)
87
+ if isinstance(node, _constants_only_allowed_node_types()):
88
+ return True
89
+ return _is_module_docstring_expression(node)
90
+
91
+
92
+ def _is_constants_only_python_content(content: str) -> bool:
93
+ """Return whether module source declares only constants, imports, docstring.
94
+
95
+ Args:
96
+ content: Python source text.
97
+
98
+ Returns:
99
+ True when every top-level statement is an import, a safe constant
100
+ assignment, or the module docstring; False on empty or unparseable
101
+ content, or any statement that carries behavior.
102
+ """
103
+ if not content.strip():
104
+ return False
105
+ try:
106
+ parsed_tree = ast.parse(content)
107
+ except SyntaxError:
108
+ return False
109
+ if not parsed_tree.body:
110
+ return False
111
+ return all(_top_level_node_is_constant(each_node) for each_node in parsed_tree.body)
112
+
113
+
114
+ def _apply_edit_to_content(
115
+ existing_content: str, old_str: str, new_str: str, should_replace_all: bool
116
+ ) -> str:
117
+ if should_replace_all:
118
+ return existing_content.replace(old_str, new_str)
119
+ return existing_content.replace(old_str, new_str, 1)
120
+
121
+
122
+ def _future_module_name() -> str:
123
+ return "__future__"
124
+
125
+
126
+ def _is_future_import(node: ast.stmt) -> bool:
127
+ return isinstance(node, ast.ImportFrom) and node.module == _future_module_name()
128
+
129
+
130
+ def _is_removable_import(node: ast.stmt) -> bool:
131
+ if isinstance(node, ast.Import):
132
+ return True
133
+ if isinstance(node, ast.ImportFrom):
134
+ return not _is_future_import(node)
135
+ return False
136
+
137
+
138
+ def _future_import_signatures(content: str) -> list[str] | None:
139
+ try:
140
+ parsed_tree = ast.parse(content)
141
+ except SyntaxError:
142
+ return None
143
+ return [ast.dump(each_node) for each_node in parsed_tree.body if _is_future_import(each_node)]
144
+
145
+
146
+ def _top_level_signatures(content: str) -> tuple[list[str], list[str]] | None:
147
+ """Split top-level statements into removable-import and other signatures.
148
+
149
+ ``from __future__`` imports and every non-import statement land in the
150
+ second list, so editing a future import reads as a behavior edit rather than
151
+ a removable-import edit. Signatures omit line and column attributes.
152
+
153
+ Args:
154
+ content: Python source text.
155
+
156
+ Returns:
157
+ A pair ``(import_signatures, other_signatures)`` in source order, or
158
+ None when the content does not parse.
159
+ """
160
+ try:
161
+ parsed_tree = ast.parse(content)
162
+ except SyntaxError:
163
+ return None
164
+ import_signatures: list[str] = []
165
+ non_import_signatures: list[str] = []
166
+ for each_node in parsed_tree.body:
167
+ if _is_removable_import(each_node):
168
+ import_signatures.append(ast.dump(each_node))
169
+ else:
170
+ non_import_signatures.append(ast.dump(each_node))
171
+ return import_signatures, non_import_signatures
172
+
173
+
174
+ def _post_edit_content_for_edit(existing_content: str, tool_input: dict) -> str | None:
175
+ old_str = tool_input.get("old_string", "") or ""
176
+ new_str = tool_input.get("new_string", "") or ""
177
+ if not old_str:
178
+ return None
179
+ should_replace_all = bool(tool_input.get("replace_all", False))
180
+ return _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
181
+
182
+
183
+ def _post_edit_content_for_multiedit(existing_content: str, tool_input: dict) -> str | None:
184
+ all_edits = tool_input.get("edits", []) or []
185
+ post_edit_content = existing_content
186
+ for each_edit in all_edits:
187
+ if not isinstance(each_edit, dict):
188
+ return None
189
+ each_old = each_edit.get("old_string", "") or ""
190
+ each_new = each_edit.get("new_string", "") or ""
191
+ if not each_old:
192
+ return None
193
+ should_replace_all = bool(each_edit.get("replace_all", False))
194
+ post_edit_content = _apply_edit_to_content(
195
+ post_edit_content, each_old, each_new, should_replace_all
196
+ )
197
+ return post_edit_content
198
+
199
+
200
+ def _compute_post_edit_content(
201
+ existing_content: str, tool_name: str, tool_input: dict
202
+ ) -> str | None:
203
+ if tool_name == "Edit":
204
+ return _post_edit_content_for_edit(existing_content, tool_input)
205
+ if tool_name == "MultiEdit":
206
+ return _post_edit_content_for_multiedit(existing_content, tool_input)
207
+ return None
208
+
209
+
210
+ def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
211
+ """Return whether an Edit or MultiEdit keeps a constants-only file so.
212
+
213
+ Both the existing content and the post-edit result must be constants-only,
214
+ and the ``from __future__`` imports must be unchanged, so a future-import
215
+ edit faces the gate rather than slipping through this exemption.
216
+
217
+ Args:
218
+ existing_content: Current file text.
219
+ tool_name: The intercepted tool (Edit or MultiEdit).
220
+ tool_input: The intercepted tool's input payload.
221
+
222
+ Returns:
223
+ True when the file was and stays constants-only across the edit.
224
+ """
225
+ if not _is_constants_only_python_content(existing_content):
226
+ return False
227
+ post_edit_content = _compute_post_edit_content(existing_content, tool_name, tool_input)
228
+ if post_edit_content is None:
229
+ return False
230
+ if _future_import_signatures(existing_content) != _future_import_signatures(post_edit_content):
231
+ return False
232
+ return _is_constants_only_python_content(post_edit_content)
233
+
234
+
235
+ def _import_signatures_are_reorder_or_removal(
236
+ all_existing_signatures: tuple[list[str], list[str]],
237
+ all_post_edit_signatures: tuple[list[str], list[str]],
238
+ ) -> bool:
239
+ existing_imports, existing_rest = all_existing_signatures
240
+ post_imports, post_rest = all_post_edit_signatures
241
+ if post_rest != existing_rest or post_imports == existing_imports:
242
+ return False
243
+ return Counter(post_imports) <= Counter(existing_imports)
244
+
245
+
246
+ def _is_post_edit_import_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
247
+ """Return whether an Edit or MultiEdit only removes or reorders imports.
248
+
249
+ Args:
250
+ existing_content: Current file text.
251
+ tool_name: The intercepted tool (Edit or MultiEdit).
252
+ tool_input: The intercepted tool's input payload.
253
+
254
+ Returns:
255
+ True when the edit is a pure reordering or removal of imports.
256
+ """
257
+ existing_signatures = _top_level_signatures(existing_content)
258
+ if existing_signatures is None:
259
+ return False
260
+ if tool_name == "MultiEdit" and not (tool_input.get("edits") or []):
261
+ return False
262
+ post_edit_content = _compute_post_edit_content(existing_content, tool_name, tool_input)
263
+ if post_edit_content is None:
264
+ return False
265
+ post_edit_signatures = _top_level_signatures(post_edit_content)
266
+ if post_edit_signatures is None:
267
+ return False
268
+ return _import_signatures_are_reorder_or_removal(existing_signatures, post_edit_signatures)
@@ -0,0 +1,92 @@
1
+ """Build and emit the gate's allow and deny decisions.
2
+
3
+ Writes the PreToolUse decision JSON to stdout and, on a deny, records the block
4
+ and attaches the short user-facing notice.
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from tdd_enforcer_parts.config.tdd_enforcer_constants import (
12
+ ENTRY_HOOK_FILE_NAME,
13
+ FRESHNESS_WINDOW_SECONDS,
14
+ NEWLINE_JOIN_SEPARATOR,
15
+ )
16
+
17
+ from hooks_constants.hook_block_logger import log_hook_block
18
+ from hooks_constants.messages import USER_FACING_TDD_NOTICE
19
+
20
+
21
+ def _entry_hook_path() -> Path:
22
+ """Return the entry hook file that wires the gate's exemption rules together.
23
+
24
+ The exemption logic the deny message points at lives in the entry hook
25
+ beside this parts package, not in this emit-only module::
26
+
27
+ tdd_enforcer_parts/decisions.py -> parent -> blocking/
28
+ blocking/tdd_enforcer.py <- the named exemption home
29
+
30
+ So the guidance names the entry hook, letting a reader land on the module
31
+ that actually decides exemptions.
32
+ """
33
+ return Path(__file__).resolve().parent.parent / ENTRY_HOOK_FILE_NAME
34
+
35
+
36
+ def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
37
+ """Return the verbose deny message naming the candidate test files.
38
+
39
+ Args:
40
+ production_path: The production file the write targets.
41
+ all_candidates: Candidate test paths the gate looked for.
42
+
43
+ Returns:
44
+ The multi-line reason shown when no fresh test satisfies the gate.
45
+ """
46
+ candidate_lines = NEWLINE_JOIN_SEPARATOR.join(f" - {each_path}" for each_path in all_candidates)
47
+ hook_source_path = _entry_hook_path()
48
+ return (
49
+ f"[TDD] Blocking write to production file: {production_path}\n"
50
+ f"No matching test file exists, or it has not been modified within the last "
51
+ f"{FRESHNESS_WINDOW_SECONDS} seconds.\n"
52
+ f"Expected one of:\n{candidate_lines}\n"
53
+ f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
54
+ f"If this file legitimately does not need a test (for example, a module containing only "
55
+ f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
56
+ f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
57
+ f"automatically. Do not add escape-hatch markers to production files."
58
+ )
59
+
60
+
61
+ def emit_allow() -> None:
62
+ """Write the allow decision to stdout."""
63
+ allow_payload = {
64
+ "hookSpecificOutput": {
65
+ "hookEventName": "PreToolUse",
66
+ "permissionDecision": "allow",
67
+ }
68
+ }
69
+ sys.stdout.write(json.dumps(allow_payload))
70
+
71
+
72
+ def emit_deny(reason: str) -> None:
73
+ """Write the deny decision to stdout and record the block.
74
+
75
+ Args:
76
+ reason: The verbose deny reason shown to the agent.
77
+ """
78
+ deny_payload = {
79
+ "hookSpecificOutput": {
80
+ "hookEventName": "PreToolUse",
81
+ "permissionDecision": "deny",
82
+ "permissionDecisionReason": reason,
83
+ },
84
+ "suppressOutput": True,
85
+ "systemMessage": USER_FACING_TDD_NOTICE,
86
+ }
87
+ log_hook_block(
88
+ calling_hook_name="tdd_enforcer.py",
89
+ hook_event="PreToolUse",
90
+ block_reason=reason,
91
+ )
92
+ sys.stdout.write(json.dumps(deny_payload))
@@ -0,0 +1,80 @@
1
+ """Freshness check for the candidate test files the gate resolves.
2
+
3
+ A candidate satisfies the gate when it was modified inside the freshness window
4
+ and holds a real test function, so a stale test or a same-named file with no
5
+ test evidence does not open the gate.
6
+ """
7
+
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from tdd_enforcer_parts.config.tdd_enforcer_constants import FRESHNESS_WINDOW_SECONDS
13
+
14
+
15
+ def _freshness_seconds() -> int:
16
+ return FRESHNESS_WINDOW_SECONDS
17
+
18
+
19
+ def _test_file_encoding() -> str:
20
+ return "utf-8"
21
+
22
+
23
+ def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
24
+ return (
25
+ re.compile(r"\bdef\s+test_"),
26
+ re.compile(r"\b(?:it|test|describe)\s*\("),
27
+ )
28
+
29
+
30
+ def _safe_mtime(candidate_path: Path) -> float | None:
31
+ try:
32
+ return candidate_path.stat().st_mtime
33
+ except (FileNotFoundError, OSError):
34
+ return None
35
+
36
+
37
+ def _read_candidate_text(candidate_path: Path) -> str | None:
38
+ try:
39
+ with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
40
+ return each_file.read()
41
+ except (FileNotFoundError, OSError):
42
+ return None
43
+
44
+
45
+ def _contains_test_evidence(candidate_path: Path) -> bool:
46
+ test_file_content = _read_candidate_text(candidate_path)
47
+ if test_file_content is None:
48
+ return False
49
+ return any(
50
+ each_pattern.search(test_file_content) for each_pattern in _test_function_patterns()
51
+ )
52
+
53
+
54
+ def _candidate_is_fresh_test(
55
+ candidate_path: Path, current_time: float, freshness_seconds: int
56
+ ) -> bool:
57
+ candidate_mtime = _safe_mtime(candidate_path)
58
+ if candidate_mtime is None:
59
+ return False
60
+ if current_time - candidate_mtime > freshness_seconds:
61
+ return False
62
+ return _contains_test_evidence(candidate_path)
63
+
64
+
65
+ def has_fresh_test(all_candidates: list[Path], freshness_seconds: int) -> bool:
66
+ """Return whether any candidate is a recently modified real test file.
67
+
68
+ Args:
69
+ all_candidates: Candidate test paths for the production file.
70
+ freshness_seconds: Maximum age, in seconds, a test may have.
71
+
72
+ Returns:
73
+ True when at least one candidate was modified within the window and
74
+ holds a test function.
75
+ """
76
+ current_time = time.time()
77
+ return any(
78
+ _candidate_is_fresh_test(each_candidate, current_time, freshness_seconds)
79
+ for each_candidate in all_candidates
80
+ )