claude-dev-env 2.3.0 → 2.5.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 (290) hide show
  1. package/CLAUDE.md +53 -48
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +3 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +3 -4
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/skill-writer-agent.md +84 -0
  31. package/agents/test_agent_frontmatter.py +67 -18
  32. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
  33. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  34. package/bin/CLAUDE.md +68 -5
  35. package/bin/ever-shipped-skills.mjs +1 -0
  36. package/bin/install-constants.mjs +88 -0
  37. package/bin/install.mjs +1138 -114
  38. package/bin/install.prune.test.mjs +869 -19
  39. package/bin/install.test.mjs +906 -2
  40. package/commands/implement.md +1 -1
  41. package/commands/right-size.md +1 -1
  42. package/docs/CLAUDE.md +2 -0
  43. package/docs/host-pool-health-monitor.md +102 -0
  44. package/docs/references/CLAUDE.md +5 -2
  45. package/docs/references/advisor-tool.md +13 -0
  46. package/docs/references/code-review-enforcement.md +107 -0
  47. package/docs/references/team-advisor-skill.md +14 -0
  48. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  49. package/hooks/blocking/CLAUDE.md +9 -1
  50. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  51. package/hooks/blocking/code_review_gate_deny.py +74 -0
  52. package/hooks/blocking/code_review_pr_create_gate.py +198 -0
  53. package/hooks/blocking/code_review_push_gate.py +145 -0
  54. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
  55. package/hooks/blocking/code_review_stamp_store.py +233 -0
  56. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  57. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  58. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  59. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  60. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  61. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  62. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  63. package/hooks/blocking/config/__init__.py +5 -5
  64. package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
  65. package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
  66. package/hooks/blocking/config/verified_commit_constants.py +160 -155
  67. package/hooks/blocking/conftest.py +2 -0
  68. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  69. package/hooks/blocking/destructive_command_blocker.py +19 -6
  70. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  71. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  72. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  73. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  74. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  75. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  76. package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
  77. package/hooks/blocking/test_code_review_push_gate.py +205 -0
  78. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
  79. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  80. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  81. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  82. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  83. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  84. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  85. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  86. package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
  87. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  88. package/hooks/blocking/test_verification_verdict_store.py +974 -903
  89. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  90. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  91. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  92. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  93. package/hooks/blocking/verification_verdict_store.py +1039 -1014
  94. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  95. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  96. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  97. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  98. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  99. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  100. package/hooks/git-hooks/pre_push.py +89 -2
  101. package/hooks/git-hooks/test_pre_push.py +128 -0
  102. package/hooks/hooks.json +26 -1
  103. package/hooks/hooks_constants/CLAUDE.md +3 -1
  104. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  105. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  106. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  107. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  108. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  109. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  110. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  111. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  112. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  113. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  114. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  115. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  116. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  117. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  118. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  119. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  120. package/hooks/pyproject.toml +1 -0
  121. package/hooks/validators/CLAUDE.md +2 -0
  122. package/hooks/validators/config/__init__.py +0 -0
  123. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  124. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  125. package/hooks/validators/conftest.py +4 -0
  126. package/hooks/validators/mypy_integration.py +63 -50
  127. package/hooks/validators/pyproject_config_discovery.py +101 -0
  128. package/hooks/validators/ruff_integration.py +257 -25
  129. package/hooks/validators/run_all_validators.py +223 -19
  130. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  131. package/hooks/validators/test_mypy_integration.py +32 -0
  132. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +160 -2
  135. package/hooks/validators/test_run_all_validators.py +140 -68
  136. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  137. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  138. package/package.json +10 -2
  139. package/rules/CLAUDE.md +1 -0
  140. package/rules/docstring-prose-matches-implementation.md +45 -67
  141. package/rules/durable-post-artifacts.md +7 -0
  142. package/rules/state-what-is.md +25 -0
  143. package/rules/verified-commit-gate-skip.md +1 -1
  144. package/scripts/CLAUDE.md +1 -0
  145. package/scripts/Capture-PoolHealth.ps1 +410 -0
  146. package/scripts/_code_review_test_support.py +404 -0
  147. package/scripts/claude_chain_runner.py +141 -1
  148. package/scripts/codec_forwarding_test_support.py +83 -0
  149. package/scripts/conftest.py +23 -0
  150. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
  151. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
  152. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  153. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  154. package/scripts/invoke_code_review.py +550 -38
  155. package/scripts/resolve_worker_spawn.py +626 -619
  156. package/scripts/spawn_grok_batch.py +672 -672
  157. package/scripts/test_claude_chain_runner.py +131 -0
  158. package/scripts/test_invoke_code_review_chain.py +70 -0
  159. package/scripts/test_invoke_code_review_cli.py +192 -0
  160. package/scripts/test_invoke_code_review_codec.py +77 -0
  161. package/scripts/test_invoke_code_review_contract.py +256 -0
  162. package/scripts/test_invoke_code_review_git.py +123 -0
  163. package/scripts/test_invoke_code_review_mode.py +99 -0
  164. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  165. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  166. package/skills/CLAUDE.md +2 -0
  167. package/skills/auditing-claude-config/SKILL.md +114 -114
  168. package/skills/autoconverge/SKILL.md +427 -421
  169. package/skills/autoconverge/reference/convergence.md +26 -4
  170. package/skills/autoconverge/reference/multi-pr.md +6 -1
  171. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  172. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  173. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  174. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  175. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
  176. package/skills/autoconverge/workflow/converge.mjs +191 -8
  177. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  178. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  179. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  180. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  181. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  182. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  183. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  184. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  185. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  186. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  187. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  188. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  189. package/skills/closeout/SKILL.md +33 -50
  190. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  191. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  192. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  193. package/skills/condensing-instructions/SKILL.md +81 -0
  194. package/skills/copilot-review/SKILL.md +119 -119
  195. package/skills/e-code-review/SKILL.md +52 -0
  196. package/skills/e-code-review/reference/fix.md +54 -0
  197. package/skills/e-code-review/reference/loop.md +43 -0
  198. package/skills/e-code-review/reference/low.md +57 -0
  199. package/skills/e-code-review/reference/medium.md +153 -0
  200. package/skills/e-code-review/reference/xhigh.md +182 -0
  201. package/skills/e-simplify/SKILL.md +97 -0
  202. package/skills/fresh-branch/CLAUDE.md +2 -0
  203. package/skills/fresh-branch/SKILL.md +2 -0
  204. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  205. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  206. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  207. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  208. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  209. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  210. package/skills/issue-tracker/SKILL.md +92 -0
  211. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  212. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  213. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  214. package/skills/orchestrator/SKILL.md +162 -21
  215. package/skills/orchestrator/scripts/status_gate.py +625 -0
  216. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  217. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  218. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  219. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  220. package/skills/orchestrator-refresh/SKILL.md +110 -35
  221. package/skills/plan-to-pr/SKILL.md +155 -0
  222. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  223. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  224. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  225. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  226. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  227. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  228. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  229. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  230. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  231. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  232. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  233. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  234. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  235. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  236. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  237. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  238. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  239. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  240. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  241. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  242. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  243. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  244. package/skills/pr-converge/SKILL.md +472 -469
  245. package/skills/pr-converge/reference/examples.md +3 -3
  246. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  247. package/skills/pr-converge/reference/ground-rules.md +7 -4
  248. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  249. package/skills/pr-converge/reference/per-tick.md +5 -5
  250. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  251. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  252. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  253. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  254. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  255. package/skills/pr-fix-protocol/SKILL.md +1 -1
  256. package/skills/privacy-hygiene/SKILL.md +68 -68
  257. package/skills/prototype/SKILL.md +86 -0
  258. package/skills/prototype/reference/honest-limitations.md +23 -0
  259. package/skills/prototype/reference/promotion-tasks.md +23 -0
  260. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  261. package/skills/prototype/scripts/conftest.py +15 -0
  262. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  263. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  264. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  265. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  266. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  267. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  268. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  269. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  270. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  271. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  272. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  273. package/skills/prototype/workflows/promotion.md +27 -0
  274. package/skills/prototype/workflows/sandbox.md +35 -0
  275. package/skills/release-notes-html/SKILL.md +164 -0
  276. package/skills/skill-builder/CLAUDE.md +3 -3
  277. package/skills/skill-builder/SKILL.md +5 -5
  278. package/skills/skill-builder/references/CLAUDE.md +1 -1
  279. package/skills/skill-builder/references/delegation-map.md +3 -3
  280. package/skills/skill-builder/references/description-field.md +1 -1
  281. package/skills/skill-builder/references/skill-modularity.md +2 -3
  282. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  283. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  284. package/skills/skill-builder/workflows/new-skill.md +2 -2
  285. package/skills/task-build/CLAUDE.md +8 -7
  286. package/skills/task-build/SKILL.md +16 -8
  287. package/skills/task-build/reference/tool-routing.md +19 -0
  288. package/skills/team-advisor/SKILL.md +2 -2
  289. package/scripts/test_invoke_code_review.py +0 -672
  290. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -19,6 +19,11 @@ from datetime import datetime
19
19
  from pathlib import Path
20
20
  from typing import Callable, Dict, List, Optional, Tuple
21
21
 
22
+ from .config.directory_exemption_constants import (
23
+ ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES,
24
+ ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS,
25
+ ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES,
26
+ )
22
27
  from .health_check import get_system_health, get_validator_version, print_health_report
23
28
  from .mypy_integration import check_mypy_available, run_mypy_check
24
29
  from .output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
@@ -413,7 +418,9 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
413
418
  )
414
419
 
415
420
 
416
- def run_ruff_checks(files: List[Path]) -> ValidatorResult:
421
+ def run_ruff_checks(
422
+ files: List[Path], config_source_path: Optional[Path] = None
423
+ ) -> ValidatorResult:
417
424
  """Run ruff for fast Python linting."""
418
425
  if not check_ruff_available():
419
426
  return ValidatorResult(
@@ -423,7 +430,7 @@ def run_ruff_checks(files: List[Path]) -> ValidatorResult:
423
430
  output="Ruff not installed - skipping",
424
431
  )
425
432
 
426
- result = run_ruff_check(files)
433
+ result = run_ruff_check(files, config_source_path)
427
434
 
428
435
  return ValidatorResult(
429
436
  name="Ruff",
@@ -433,7 +440,9 @@ def run_ruff_checks(files: List[Path]) -> ValidatorResult:
433
440
  )
434
441
 
435
442
 
436
- def run_mypy_checks(files: List[Path]) -> ValidatorResult:
443
+ def run_mypy_checks(
444
+ files: List[Path], config_source_path: Optional[Path] = None
445
+ ) -> ValidatorResult:
437
446
  """Run mypy for static type checking."""
438
447
  if not check_mypy_available():
439
448
  return ValidatorResult(
@@ -443,7 +452,7 @@ def run_mypy_checks(files: List[Path]) -> ValidatorResult:
443
452
  output="Mypy not installed - skipping",
444
453
  )
445
454
 
446
- result = run_mypy_check(files)
455
+ result = run_mypy_check(files, config_source_path)
447
456
 
448
457
  return ValidatorResult(
449
458
  name="Mypy",
@@ -727,15 +736,16 @@ def reconstruct_proposed_content(
727
736
  return apply_edits(existing_content, edits_for_tool(tool_name, dict(tool_input)))
728
737
 
729
738
 
730
- def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
731
- """Run every validator scoped to individual files against *all_files*.
739
+ def run_file_scoped_validators(
740
+ all_files: List[Path], config_source_path: Optional[Path] = None
741
+ ) -> List[ValidatorResult]:
742
+ """Run every file-scoped validator against *all_files*.
732
743
 
733
- Excludes the branch-scoped File Structure and Git validators, which grade
734
- the whole project rather than a single proposed file.
744
+ Excludes the branch-scoped File Structure and Git validators.
735
745
 
736
746
  Args:
737
- all_files: The files under validation — a single reconstructed file in
738
- gate mode.
747
+ all_files: The files under validation — one reconstructed file in gate mode.
748
+ config_source_path: Original path ruff and mypy resolve their config from.
739
749
 
740
750
  Returns:
741
751
  One ValidatorResult per file-scoped validator, in run order.
@@ -744,8 +754,8 @@ def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
744
754
  run_python_style_checks(all_files),
745
755
  run_test_safety_checks(all_files),
746
756
  run_react_checks(all_files),
747
- run_ruff_checks(all_files),
748
- run_mypy_checks(all_files),
757
+ run_ruff_checks(all_files, config_source_path),
758
+ run_mypy_checks(all_files, config_source_path),
749
759
  run_abbreviation_checks(all_files),
750
760
  run_pr_reference_checks(all_files),
751
761
  run_magic_value_checks(all_files),
@@ -758,27 +768,221 @@ def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
758
768
  ]
759
769
 
760
770
 
771
+ def _escapes_temporary_root(path_part: str) -> bool:
772
+ """Return True when a path part would climb out of or re-anchor the temp root.
773
+
774
+ ::
775
+
776
+ ok: "scripts" -> False (stays inside)
777
+ flag: ".." -> True (climbs a level)
778
+ flag: "/etc" -> True (absolute, re-anchors the join)
779
+
780
+ A ``..`` component escapes upward and an absolute or anchored component
781
+ re-roots the join away from the temporary directory. Both are dropped so a
782
+ staged file cannot land outside the ephemeral root.
783
+
784
+ Args:
785
+ path_part: A single path component drawn from the destination path.
786
+
787
+ Returns:
788
+ True when the component must be dropped to keep staging contained.
789
+ """
790
+ if path_part == os.pardir:
791
+ return True
792
+ part_as_path = Path(path_part)
793
+ return part_as_path.is_absolute() or bool(part_as_path.anchor)
794
+
795
+
796
+ def _all_system_temporary_roots() -> tuple[Path, ...]:
797
+ """Return resolved roots that count as system temporary directories.
798
+
799
+ ::
800
+
801
+ gettempdir() plus TEMP / TMP / TMPDIR / RUNNER_TEMP when set.
802
+
803
+ GitHub Actions puts pytest basetemp under ``RUNNER_TEMP``
804
+ (``/home/runner/work/_temp``) while ``tempfile.gettempdir()`` is ``/tmp``.
805
+ Both must count so pytest-shaped ``test_*`` parents disable substring
806
+ exemption matching during staging.
807
+ """
808
+ all_candidate_roots: list[str] = [tempfile.gettempdir()]
809
+ for each_environment_name in ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES:
810
+ environment_value = os.environ.get(each_environment_name)
811
+ if environment_value:
812
+ all_candidate_roots.append(environment_value)
813
+ all_resolved_roots: list[Path] = []
814
+ for each_candidate in all_candidate_roots:
815
+ try:
816
+ all_resolved_roots.append(Path(each_candidate).resolve())
817
+ except OSError:
818
+ continue
819
+ return tuple(all_resolved_roots)
820
+
821
+
822
+ def _is_absolute_path_under_system_temporary_directory(file_path: str) -> bool:
823
+ """Return True when *file_path* is absolute and resolves under an OS temp root.
824
+
825
+ ::
826
+
827
+ ok: "C:/Users/.../Temp/pytest-of-x/test_foo0/a.py" -> True
828
+ ok: "/home/runner/work/_temp/pytest-basetemp/.../a.py" when RUNNER_TEMP
829
+ flag: "packages/demo/test_helpers/worker.py" -> False (relative)
830
+ flag: "C:/repo/pkg/test_helpers/worker.py" -> False (project)
831
+
832
+ Absolute paths under system temporary directories often carry pytest
833
+ ``tmp_path`` segments named ``test_*``. Those segments must not be treated
834
+ as project exemption signals, or a production file staged from a pytest
835
+ path would inherit a false test-file exemption.
836
+
837
+ Args:
838
+ file_path: Destination path the write or edit targets.
839
+
840
+ Returns:
841
+ True when *file_path* is absolute and lives under ``tempfile.gettempdir()``
842
+ or a known temporary-root environment variable (``TEMP``, ``TMP``,
843
+ ``TMPDIR``, ``RUNNER_TEMP``).
844
+ """
845
+ destination_path = Path(file_path)
846
+ if not destination_path.is_absolute():
847
+ return False
848
+ try:
849
+ resolved_destination = destination_path.resolve()
850
+ except OSError:
851
+ return False
852
+ return any(
853
+ resolved_destination.is_relative_to(each_temporary_root)
854
+ for each_temporary_root in _all_system_temporary_roots()
855
+ )
856
+
857
+
858
+ def _directory_segment_signals_exemption(
859
+ segment_lower: str, *, is_substring_match_enabled: bool
860
+ ) -> bool:
861
+ """Return True when a directory segment carries a path-exemption signal.
862
+
863
+ ::
864
+
865
+ ok: "test_helpers" (substring on) -> True (contains the ``test_`` fragment)
866
+ ok: "scripts" -> True (exact CLI-marker segment name)
867
+ flag: "latest" -> False (neither exact name nor a fragment)
868
+ flag: "test_edit_foo0" (substring off) -> False (pytest-shaped; exact-only mode)
869
+
870
+ A segment is kept during staging when its name exactly matches an exemption
871
+ directory name. When *is_substring_match_enabled* is True, a separator-free
872
+ substring pattern that ``is_test_file`` matches anywhere in a path also
873
+ counts — that is what preserves ``pkg/test_helpers/``. Substring matching is
874
+ disabled for absolute paths under the system temporary directory so pytest
875
+ ``tmp_path`` parents named ``test_*`` do not falsely trip test exemptions.
876
+
877
+ Args:
878
+ segment_lower: One lowercased directory component of the target path.
879
+ is_substring_match_enabled: When False, only exact exemption names count.
880
+
881
+ Returns:
882
+ True when staging must keep this segment to reproduce the exemption.
883
+ """
884
+ if segment_lower in ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES:
885
+ return True
886
+ if not is_substring_match_enabled:
887
+ return False
888
+ return any(
889
+ each_pattern in segment_lower
890
+ for each_pattern in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
891
+ )
892
+
893
+
894
+ def _temporary_path_preserving_directory_signal(
895
+ temporary_directory: Path, file_path: str
896
+ ) -> Path:
897
+ """Build a temp path that keeps exemption directory tails and the basename.
898
+
899
+ Directory-name exemptions (``/scripts/``, ``/tests/``) and separator-free
900
+ path fragments (``test_`` inside ``test_helpers``) match substrings of the
901
+ real file path. Staging under a flat temp basename drops those segments.
902
+ Mirroring the first exemption-signaling directory through the basename
903
+ restores that signal. Substring fragment matching is skipped for absolute
904
+ paths under the system temporary directory so pytest ``tmp_path`` parents
905
+ that contain ``test_`` do not falsely trip test-file exemptions; exact
906
+ exemption directory names (``config``, ``scripts``, ``tests``, ...) still
907
+ anchor staging on those absolute temp paths.
908
+
909
+ Args:
910
+ temporary_directory: Root of the ephemeral staging tree.
911
+ file_path: Real destination path the write or edit targets.
912
+
913
+ Returns:
914
+ Path under *temporary_directory* ending in the exemption directory tail
915
+ (when present) and the real basename.
916
+ """
917
+ destination_path = Path(file_path)
918
+ path_parts = destination_path.parts
919
+ if destination_path.anchor:
920
+ path_parts = path_parts[1:]
921
+ if not path_parts:
922
+ path_parts = (destination_path.name,)
923
+
924
+ is_substring_match_enabled = not _is_absolute_path_under_system_temporary_directory(
925
+ file_path
926
+ )
927
+ start_index = len(path_parts) - 1
928
+ for each_index, each_part in enumerate(path_parts[:-1]):
929
+ part_lower = each_part.lower()
930
+ if not _directory_segment_signals_exemption(
931
+ part_lower, is_substring_match_enabled=is_substring_match_enabled
932
+ ):
933
+ continue
934
+ start_index = each_index
935
+ break
936
+
937
+ selected_parts = path_parts[start_index:]
938
+ contained_parts = tuple(
939
+ each_part for each_part in selected_parts if not _escapes_temporary_root(each_part)
940
+ )
941
+ if not contained_parts:
942
+ contained_parts = (destination_path.name,)
943
+ temporary_file = temporary_directory.joinpath(*contained_parts)
944
+ if not temporary_file.resolve().is_relative_to(temporary_directory.resolve()):
945
+ temporary_file = temporary_directory / destination_path.name
946
+ temporary_file.parent.mkdir(parents=True, exist_ok=True)
947
+ return temporary_file
948
+
949
+
761
950
  def validate_proposed_file(
762
- file_path: str, proposed_content: str
951
+ file_path: str,
952
+ proposed_content: str,
953
+ config_source_path: Optional[Path] = None,
763
954
  ) -> List[ValidatorResult]:
764
955
  """Validate *proposed_content* as if written to *file_path*.
765
956
 
766
- Writes the content to a temporary file that carries the target's basename so
767
- suffix-based and test-name-based validator filtering matches the real path,
768
- then runs the file-scoped validators against it.
957
+ Writes the content to a temporary file that preserves the exemption-relevant
958
+ directory tail and basename so directory-based exemptions, suffix-based
959
+ filtering, and test-name-based filtering match the real path, then runs the
960
+ file-scoped validators against it. Ruff and mypy resolve their config by
961
+ walking up from the config source path, so the staged copy is graded under
962
+ the project config the real path sits in rather than the temp directory's.
769
963
 
770
964
  Args:
771
965
  file_path: The destination path the write or edit targets.
772
966
  proposed_content: The reconstructed post-edit content of that file.
967
+ config_source_path: Path ruff and mypy resolve their config from;
968
+ defaults to *file_path* when the caller passes nothing.
773
969
 
774
970
  Returns:
775
971
  One ValidatorResult per file-scoped validator.
776
972
  """
777
- base_name = Path(file_path).name
778
973
  with tempfile.TemporaryDirectory() as temporary_directory:
779
- temporary_file = Path(temporary_directory) / base_name
974
+ temporary_file = _temporary_path_preserving_directory_signal(
975
+ Path(temporary_directory), file_path
976
+ )
780
977
  temporary_file.write_text(proposed_content, encoding="utf-8")
781
- return run_file_scoped_validators([temporary_file])
978
+ resolved_config_source_path = (
979
+ config_source_path
980
+ if config_source_path is not None
981
+ else Path(file_path)
982
+ )
983
+ return run_file_scoped_validators(
984
+ [temporary_file], config_source_path=resolved_config_source_path
985
+ )
782
986
 
783
987
 
784
988
  def _validator_summaries(results: List[ValidatorResult]) -> str:
@@ -0,0 +1,185 @@
1
+ """Tests for directory-exemption segment derivation.
2
+
3
+ Proves segment names come from authoritative path-exemption patterns and CLI
4
+ markers rather than hand-copied directory-name literals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
14
+ from hooks_constants.code_rules_path_utils_constants import ALL_CONFIG_DIRECTORY_NAMES
15
+ from validators.config.directory_exemption_constants import (
16
+ ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES,
17
+ ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS,
18
+ ALL_DIRECTORY_SEGMENTS_FROM_PATH_PATTERNS,
19
+ all_directory_segments_in_path_pattern,
20
+ directory_segment_names_from_path_patterns,
21
+ is_filename_like_path_segment,
22
+ substring_patterns_from_path_patterns,
23
+ )
24
+ from validators.exempt_paths import (
25
+ HOOK_INFRASTRUCTURE_PATTERNS,
26
+ MIGRATION_PATH_PATTERNS,
27
+ TEST_PATH_PATTERNS,
28
+ WORKFLOW_REGISTRY_PATTERNS,
29
+ )
30
+ from validators.run_all_validators import _temporary_path_preserving_directory_signal
31
+
32
+ ALL_KNOWN_DIRECTORY_EXEMPTION_SEGMENTS = frozenset(
33
+ {"scripts", "tests", "migrations", "workflow", "hooks"}
34
+ )
35
+ ALL_SYNTHETIC_DIRECTORY_PATTERNS = frozenset(
36
+ {"/foo/", "\\bar\\", "/baz.py", "plain", "/.hidden/qux/"}
37
+ )
38
+ ALL_EXPECTED_SYNTHETIC_SEGMENTS = frozenset({"foo", "bar", ".hidden", "qux"})
39
+
40
+
41
+ def test_is_filename_like_path_segment_detects_extensions() -> None:
42
+ assert is_filename_like_path_segment("states.py") is True
43
+ assert is_filename_like_path_segment("cli.py") is True
44
+ assert is_filename_like_path_segment("tests") is False
45
+ assert is_filename_like_path_segment(".claude") is False
46
+
47
+
48
+ def test_all_directory_segments_in_path_pattern_keeps_directory_tokens_only() -> None:
49
+ assert all_directory_segments_in_path_pattern("/.claude/hooks/") == [
50
+ ".claude",
51
+ "hooks",
52
+ ]
53
+ assert all_directory_segments_in_path_pattern("\\tests\\") == ["tests"]
54
+ assert all_directory_segments_in_path_pattern("/tests.py") == []
55
+ assert all_directory_segments_in_path_pattern("conftest") == []
56
+ assert all_directory_segments_in_path_pattern("_tab.py") == []
57
+
58
+
59
+ def test_directory_segment_names_from_path_patterns_extracts_new_directory_token() -> None:
60
+ all_segment_names = directory_segment_names_from_path_patterns(
61
+ ALL_SYNTHETIC_DIRECTORY_PATTERNS
62
+ )
63
+ assert all_segment_names == ALL_EXPECTED_SYNTHETIC_SEGMENTS
64
+
65
+
66
+ def test_derived_segments_cover_authoritative_directory_patterns() -> None:
67
+ all_authoritative_patterns = (
68
+ set(TEST_PATH_PATTERNS)
69
+ | set(WORKFLOW_REGISTRY_PATTERNS)
70
+ | set(MIGRATION_PATH_PATTERNS)
71
+ | set(HOOK_INFRASTRUCTURE_PATTERNS)
72
+ | set(ALL_CLI_FILE_PATH_MARKERS)
73
+ )
74
+ all_expected_segments = directory_segment_names_from_path_patterns(
75
+ all_authoritative_patterns
76
+ )
77
+ assert all_expected_segments == ALL_DIRECTORY_SEGMENTS_FROM_PATH_PATTERNS
78
+ assert all_expected_segments <= ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES
79
+ assert ALL_CONFIG_DIRECTORY_NAMES <= ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES
80
+ assert ALL_KNOWN_DIRECTORY_EXEMPTION_SEGMENTS <= ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES
81
+
82
+
83
+ def test_temporary_path_preserves_scripts_directory_segment(tmp_path: Path) -> None:
84
+ staged_path = _temporary_path_preserving_directory_signal(
85
+ tmp_path, "packages/demo/scripts/run_job.py"
86
+ )
87
+ assert staged_path == tmp_path / "scripts" / "run_job.py"
88
+
89
+
90
+ def test_temporary_path_preserves_claude_hooks_parent_and_child(tmp_path: Path) -> None:
91
+ staged_hooks_path = _temporary_path_preserving_directory_signal(
92
+ tmp_path, "home/.claude/hooks/blocking/gate.py"
93
+ )
94
+ assert staged_hooks_path == (
95
+ tmp_path / ".claude" / "hooks" / "blocking" / "gate.py"
96
+ )
97
+
98
+
99
+ def test_temporary_path_preserves_tests_directory_segment(tmp_path: Path) -> None:
100
+ staged_path = _temporary_path_preserving_directory_signal(
101
+ tmp_path, "packages/demo/tests/helper_functions.py"
102
+ )
103
+ assert staged_path == tmp_path / "tests" / "helper_functions.py"
104
+
105
+
106
+ def test_temporary_path_preserves_substring_test_helpers_directory(
107
+ tmp_path: Path,
108
+ ) -> None:
109
+ staged_path = _temporary_path_preserving_directory_signal(
110
+ tmp_path, "packages/demo/test_helpers/worker.py"
111
+ )
112
+ assert staged_path == tmp_path / "test_helpers" / "worker.py"
113
+
114
+
115
+ def test_temporary_path_preserves_absolute_project_test_helpers_directory(
116
+ tmp_path: Path,
117
+ ) -> None:
118
+ absolute_helpers_path = "C:/workspace/pkg/test_helpers/worker.py"
119
+ staged_path = _temporary_path_preserving_directory_signal(
120
+ tmp_path, absolute_helpers_path
121
+ )
122
+ assert staged_path == tmp_path / "test_helpers" / "worker.py"
123
+
124
+
125
+ def test_absolute_system_temp_pytest_shaped_path_stages_flat_basename(
126
+ tmp_path: Path,
127
+ ) -> None:
128
+ pytest_shaped_target = (
129
+ tmp_path / "test_edit_introducing_new_viol0" / "legacy_module.py"
130
+ )
131
+ staging_root = tmp_path / "staging_root"
132
+ staging_root.mkdir()
133
+ staged_path = _temporary_path_preserving_directory_signal(
134
+ staging_root, str(pytest_shaped_target)
135
+ )
136
+ assert staged_path == staging_root / "legacy_module.py"
137
+
138
+
139
+ def test_runner_temp_pytest_shaped_path_stages_flat_basename_when_gettempdir_differs(
140
+ tmp_path: Path,
141
+ monkeypatch: pytest.MonkeyPatch,
142
+ ) -> None:
143
+ """GHA basetemp lives under RUNNER_TEMP while gettempdir is often /tmp."""
144
+ runner_temp_root = tmp_path / "runner_temp"
145
+ os_gettemp = tmp_path / "os_gettemp"
146
+ runner_temp_root.mkdir()
147
+ os_gettemp.mkdir()
148
+ monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
149
+ monkeypatch.setattr(
150
+ "validators.run_all_validators.tempfile.gettempdir",
151
+ lambda: str(os_gettemp),
152
+ )
153
+ pytest_shaped_target = (
154
+ runner_temp_root
155
+ / "pytest-basetemp-pkg"
156
+ / "test_edit_introducing_new_viol0"
157
+ / "legacy_module.py"
158
+ )
159
+ staging_root = tmp_path / "staging_root"
160
+ staging_root.mkdir()
161
+ staged_path = _temporary_path_preserving_directory_signal(
162
+ staging_root, str(pytest_shaped_target)
163
+ )
164
+ assert staged_path == staging_root / "legacy_module.py"
165
+
166
+
167
+ def test_substring_patterns_from_path_patterns_skips_directory_tokens() -> None:
168
+ all_substring_patterns = substring_patterns_from_path_patterns(
169
+ {"test_", "/tests/", "conftest", "\\tests\\"}
170
+ )
171
+ assert all_substring_patterns == frozenset({"test_", "conftest"})
172
+
173
+
174
+ def test_directory_exemption_substring_patterns_are_non_empty_and_separator_free() -> None:
175
+ assert ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
176
+ for each_pattern in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS:
177
+ assert "/" not in each_pattern
178
+ assert "\\" not in each_pattern
179
+
180
+
181
+ def test_directory_exemption_substring_patterns_include_test_fragments() -> None:
182
+ assert "test_" in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
183
+ assert "_test." in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
184
+ assert ".spec." in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
185
+ assert "conftest" in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
@@ -165,6 +165,37 @@ def test_run_mypy_check_accepts_relative_path_under_nested_root(
165
165
  assert mypy_result.passed, mypy_result.output
166
166
 
167
167
 
168
+ def test_run_mypy_check_applies_config_resolved_from_config_source_path(
169
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
170
+ ) -> None:
171
+ """A staged file is checked under the config resolved from the original path.
172
+
173
+ A strict ``[tool.mypy]`` config flags an untyped def only when
174
+ ``config_source_path`` resolves it; native discovery from the detached
175
+ file finds no config and passes.
176
+ """
177
+ strict_repo = tmp_path / "strict_repo"
178
+ strict_repo.mkdir()
179
+ (strict_repo / "pyproject.toml").write_text(
180
+ "[tool.mypy]\ndisallow_untyped_defs = true\n", encoding="utf-8"
181
+ )
182
+ detached_file = tmp_path / "detached" / "untyped.py"
183
+ detached_file.parent.mkdir(parents=True)
184
+ detached_file.write_text(
185
+ "def annotate_nothing(value):\n return value\n", encoding="utf-8"
186
+ )
187
+
188
+ monkeypatch.chdir(tmp_path)
189
+
190
+ without_source = run_mypy_check([detached_file])
191
+ with_source = run_mypy_check(
192
+ [detached_file], config_source_path=strict_repo / "pyproject.toml"
193
+ )
194
+
195
+ assert without_source.passed, without_source.output
196
+ assert not with_source.passed
197
+
198
+
168
199
  def test_mypy_result_dataclass() -> None:
169
200
  """Test MypyResult dataclass creation."""
170
201
  result = MypyResult(passed=True, output="test", error_count=0)
@@ -183,6 +214,7 @@ def test_run_mypy_check_returns_passed_for_empty_files() -> None:
183
214
  """Test that run_mypy_check passes with no files."""
184
215
  result = run_mypy_check([])
185
216
  assert result.passed is True
217
+ assert result.error_count == 0
186
218
  assert "No files" in result.output
187
219
 
188
220
 
@@ -0,0 +1,94 @@
1
+ """Tests for the shared pyproject config-discovery walk-up primitive.
2
+
3
+ The primitive walks up from a starting path and returns the first pyproject.toml
4
+ whose ``[tool.<name>]`` table exists, so each tool matches only a config that
5
+ actually configures it — a mypy-only pyproject is no ruff config.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from hooks_constants.pyproject_config_discovery_constants import (
11
+ MYPY_TOOL_TABLE_NAME,
12
+ RUFF_TOOL_TABLE_NAME,
13
+ )
14
+
15
+ from .pyproject_config_discovery import (
16
+ ancestor_directories,
17
+ find_pyproject_configuring_tool,
18
+ )
19
+
20
+
21
+ def test_ancestor_directories_lists_directory_then_parents_nearest_first(
22
+ tmp_path: Path,
23
+ ) -> None:
24
+ nested_directory = tmp_path / "outer" / "inner"
25
+ nested_directory.mkdir(parents=True)
26
+ nested_file = nested_directory / "module.py"
27
+ nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
28
+
29
+ walked = ancestor_directories(nested_file)
30
+
31
+ assert walked[0] == nested_directory.resolve()
32
+ assert walked[1] == (tmp_path / "outer").resolve()
33
+
34
+
35
+ def test_find_pyproject_configuring_tool_returns_table_owning_pyproject(
36
+ tmp_path: Path,
37
+ ) -> None:
38
+ project_root = tmp_path / "project"
39
+ nested_directory = project_root / "src" / "deep"
40
+ nested_directory.mkdir(parents=True)
41
+ owning_pyproject = project_root / "pyproject.toml"
42
+ owning_pyproject.write_text("[tool.ruff.lint]\nselect = ['B']\n", encoding="utf-8")
43
+ target_file = nested_directory / "module.py"
44
+
45
+ found = find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME)
46
+
47
+ assert found == owning_pyproject
48
+
49
+
50
+ def test_find_pyproject_configuring_tool_skips_pyproject_without_the_table(
51
+ tmp_path: Path,
52
+ ) -> None:
53
+ outer_root = tmp_path / "outer"
54
+ inner_root = outer_root / "inner"
55
+ inner_root.mkdir(parents=True)
56
+ outer_pyproject = outer_root / "pyproject.toml"
57
+ outer_pyproject.write_text("[tool.mypy]\nignore_missing_imports = true\n", encoding="utf-8")
58
+ (inner_root / "pyproject.toml").write_text("[project]\nname = 'inner'\n", encoding="utf-8")
59
+ target_file = inner_root / "module.py"
60
+
61
+ found = find_pyproject_configuring_tool(target_file, MYPY_TOOL_TABLE_NAME)
62
+
63
+ assert found == outer_pyproject
64
+
65
+
66
+ def test_find_pyproject_configuring_tool_is_table_specific(tmp_path: Path) -> None:
67
+ project_root = tmp_path / "mypy_only"
68
+ project_root.mkdir()
69
+ (project_root / "pyproject.toml").write_text(
70
+ "[tool.mypy]\nignore_missing_imports = true\n", encoding="utf-8"
71
+ )
72
+ target_file = project_root / "module.py"
73
+
74
+ assert find_pyproject_configuring_tool(target_file, MYPY_TOOL_TABLE_NAME) is not None
75
+ assert find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME) is None
76
+
77
+
78
+ def test_find_pyproject_configuring_tool_returns_none_when_no_match(tmp_path: Path) -> None:
79
+ isolated_directory = tmp_path / "isolated"
80
+ isolated_directory.mkdir()
81
+ target_file = isolated_directory / "module.py"
82
+
83
+ assert find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME) is None
84
+
85
+
86
+ def test_find_pyproject_configuring_tool_treats_malformed_toml_as_no_match(
87
+ tmp_path: Path,
88
+ ) -> None:
89
+ project_root = tmp_path / "broken"
90
+ project_root.mkdir()
91
+ (project_root / "pyproject.toml").write_text("[tool.ruff\nbroken = true\n", encoding="utf-8")
92
+ target_file = project_root / "module.py"
93
+
94
+ assert find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME) is None