claude-dev-env 2.4.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  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 +2 -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 +0 -1
  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/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,5 +1,6 @@
1
1
  """Ruff integration for fast Python linting."""
2
2
 
3
+ import os
3
4
  import subprocess
4
5
  import sys
5
6
  from dataclasses import dataclass
@@ -16,13 +17,25 @@ except ModuleNotFoundError:
16
17
  from pyproject_config_discovery import find_pyproject_configuring_tool
17
18
 
18
19
  try:
20
+ from hooks_constants.blocking_check_limits import RUFF_STDIN_ENCODING
19
21
  from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
20
22
  from hooks_constants.pyproject_config_discovery_constants import RUFF_TOOL_TABLE_NAME
23
+ from hooks_constants.ruff_integration_constants import (
24
+ FORCE_COLOR_ENVIRONMENT_VARIABLE_NAME,
25
+ NO_COLOR_ENABLED_VALUE,
26
+ NO_COLOR_ENVIRONMENT_VARIABLE_NAME,
27
+ )
21
28
  except ModuleNotFoundError:
22
29
  if _hooks_directory not in sys.path:
23
30
  sys.path.insert(0, _hooks_directory)
31
+ from hooks_constants.blocking_check_limits import RUFF_STDIN_ENCODING
24
32
  from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
25
33
  from hooks_constants.pyproject_config_discovery_constants import RUFF_TOOL_TABLE_NAME
34
+ from hooks_constants.ruff_integration_constants import (
35
+ FORCE_COLOR_ENVIRONMENT_VARIABLE_NAME,
36
+ NO_COLOR_ENABLED_VALUE,
37
+ NO_COLOR_ENVIRONMENT_VARIABLE_NAME,
38
+ )
26
39
 
27
40
 
28
41
  @dataclass
@@ -46,6 +59,19 @@ def check_ruff_available() -> bool:
46
59
  return False
47
60
 
48
61
 
62
+ def _ruff_subprocess_environment() -> dict[str, str]:
63
+ """Return an environment that forces plain, non-ANSI ruff diagnostics.
64
+
65
+ The PreToolUse gate parses ``path:line:col:`` prefixes. ANSI color codes
66
+ wrap those fields when FORCE_COLOR is set, so line numbers no longer parse
67
+ and baseline scoping fail-closes every ruff finding as new.
68
+ """
69
+ ruff_environment = os.environ.copy()
70
+ ruff_environment[NO_COLOR_ENVIRONMENT_VARIABLE_NAME] = NO_COLOR_ENABLED_VALUE
71
+ ruff_environment.pop(FORCE_COLOR_ENVIRONMENT_VARIABLE_NAME, None)
72
+ return ruff_environment
73
+
74
+
49
75
  def find_pyproject_with_ruff_config(starting_file: Path) -> Path | None:
50
76
  """Walk up from a starting file to a pyproject.toml that configures ruff.
51
77
 
@@ -108,9 +134,14 @@ def _config_relative_stdin_filename(target_path: Path, config_directory: Path) -
108
134
  The config-relative POSIX path when the target sits under the config
109
135
  directory, else the target's basename.
110
136
  """
137
+ try:
138
+ return target_path.relative_to(config_directory).as_posix()
139
+ except ValueError:
140
+ pass
111
141
  resolved_target = target_path.resolve()
142
+ resolved_config_directory = config_directory.resolve()
112
143
  try:
113
- return resolved_target.relative_to(config_directory).as_posix()
144
+ return resolved_target.relative_to(resolved_config_directory).as_posix()
114
145
  except ValueError:
115
146
  return resolved_target.name
116
147
 
@@ -151,14 +182,25 @@ def _staged_ruff_command(resolved_pyproject: Path, stdin_filename: str) -> list[
151
182
  def _staged_ruff_result(
152
183
  resolved_pyproject: Path, stdin_filename: str, staged_content: str
153
184
  ) -> RuffResult:
154
- """Pipe *staged_content* to ruff from the config directory and wrap the result."""
185
+ """Pipe *staged_content* to ruff from the config directory and wrap the result.
186
+
187
+ ``encoding`` pins the stdin encode to UTF-8, which ruff requires, regardless
188
+ of the host locale codec. ``errors="replace"`` keeps the stdout/stderr decode
189
+ tolerant: a stray non-UTF-8 byte in ruff's output becomes the replacement
190
+ character rather than raising, which under strict decoding drops the whole
191
+ stream on Windows and raises out of ``subprocess.run`` on POSIX. ``replace``
192
+ is chosen over ``surrogateescape`` so the decoded text never plants a lone
193
+ surrogate that would raise at a later strict re-encode.
194
+ """
155
195
  result = subprocess.run(
156
196
  _staged_ruff_command(resolved_pyproject, stdin_filename),
157
197
  check=False,
158
198
  capture_output=True,
159
- text=True,
199
+ encoding=RUFF_STDIN_ENCODING,
200
+ errors="replace",
160
201
  input=staged_content,
161
202
  cwd=str(resolved_pyproject.parent),
203
+ env=_ruff_subprocess_environment(),
162
204
  )
163
205
  return RuffResult(
164
206
  passed=result.returncode == 0,
@@ -204,6 +246,7 @@ def _run_native_ruff_check(
204
246
  check=False,
205
247
  capture_output=True,
206
248
  text=True,
249
+ env=_ruff_subprocess_environment(),
207
250
  )
208
251
  return RuffResult(
209
252
  passed=result.returncode == 0,
@@ -250,12 +293,12 @@ def _parse_fixed_count(fix_output: str) -> int:
250
293
  return 0
251
294
 
252
295
 
253
- def run_ruff_fix(files: list[Path]) -> RuffResult:
296
+ def run_ruff_fix(all_files: list[Path]) -> RuffResult:
254
297
  """Run ruff with --fix to auto-fix violations."""
255
298
  if not check_ruff_available():
256
299
  return RuffResult(passed=True, output="Ruff not installed", fixed_count=0)
257
300
  py_files = [
258
- str(each_file) for each_file in files if each_file.suffix == PYTHON_SOURCE_SUFFIX
301
+ str(each_file) for each_file in all_files if each_file.suffix == PYTHON_SOURCE_SUFFIX
259
302
  ]
260
303
  if not py_files:
261
304
  return RuffResult(passed=True, output="No Python files", fixed_count=0)
@@ -264,6 +307,7 @@ def run_ruff_fix(files: list[Path]) -> RuffResult:
264
307
  check=False,
265
308
  capture_output=True,
266
309
  text=True,
310
+ env=_ruff_subprocess_environment(),
267
311
  )
268
312
  return RuffResult(
269
313
  passed=result.returncode == 0,
@@ -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
@@ -763,29 +768,221 @@ def run_file_scoped_validators(
763
768
  ]
764
769
 
765
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
+
766
950
  def validate_proposed_file(
767
- file_path: str, proposed_content: str
951
+ file_path: str,
952
+ proposed_content: str,
953
+ config_source_path: Optional[Path] = None,
768
954
  ) -> List[ValidatorResult]:
769
955
  """Validate *proposed_content* as if written to *file_path*.
770
956
 
771
- Writes the content to a temporary file that carries the target's basename so
772
- suffix-based and test-name-based validator filtering matches the real path,
773
- then runs the file-scoped validators against it. Ruff and mypy resolve their
774
- config by walking up from *file_path*, so the staged copy is graded under the
775
- project config the real path sits in rather than the temp directory's.
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.
776
963
 
777
964
  Args:
778
965
  file_path: The destination path the write or edit targets.
779
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.
780
969
 
781
970
  Returns:
782
971
  One ValidatorResult per file-scoped validator.
783
972
  """
784
- base_name = Path(file_path).name
785
973
  with tempfile.TemporaryDirectory() as temporary_directory:
786
- temporary_file = Path(temporary_directory) / base_name
974
+ temporary_file = _temporary_path_preserving_directory_signal(
975
+ Path(temporary_directory), file_path
976
+ )
787
977
  temporary_file.write_text(proposed_content, encoding="utf-8")
788
- return run_file_scoped_validators([temporary_file], Path(file_path))
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
+ )
789
986
 
790
987
 
791
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