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
@@ -4,19 +4,33 @@ import contextlib
4
4
  import subprocess
5
5
  import sys
6
6
  import tempfile
7
- import tomllib
8
7
  from collections.abc import Iterator
9
8
  from dataclasses import dataclass
10
9
  from pathlib import Path
11
10
 
11
+ _validators_directory = str(Path(__file__).resolve().parent)
12
12
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
13
13
 
14
+ try:
15
+ from pyproject_config_discovery import (
16
+ ancestor_directories,
17
+ find_pyproject_configuring_tool,
18
+ )
19
+ except ModuleNotFoundError:
20
+ if _validators_directory not in sys.path:
21
+ sys.path.insert(0, _validators_directory)
22
+ from pyproject_config_discovery import (
23
+ ancestor_directories,
24
+ find_pyproject_configuring_tool,
25
+ )
26
+
14
27
  try:
15
28
  from hooks_constants.mypy_integration_constants import (
16
29
  GIT_DIRECTORY_NAME,
17
30
  PYPROJECT_FILENAME,
18
31
  PYTHON_SOURCE_SUFFIX,
19
32
  )
33
+ from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
20
34
  except ModuleNotFoundError:
21
35
  if _hooks_directory not in sys.path:
22
36
  sys.path.insert(0, _hooks_directory)
@@ -25,6 +39,7 @@ except ModuleNotFoundError:
25
39
  PYPROJECT_FILENAME,
26
40
  PYTHON_SOURCE_SUFFIX,
27
41
  )
42
+ from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
28
43
 
29
44
 
30
45
  @dataclass
@@ -48,38 +63,6 @@ def check_mypy_available() -> bool:
48
63
  return False
49
64
 
50
65
 
51
- def _pyproject_contains_tool_mypy(pyproject_path: Path) -> bool:
52
- try:
53
- with pyproject_path.open("rb") as readable_handle:
54
- parsed_toml = tomllib.load(readable_handle)
55
- except (OSError, tomllib.TOMLDecodeError):
56
- return False
57
- tool_table = parsed_toml.get("tool", {})
58
- return isinstance(tool_table, dict) and "mypy" in tool_table
59
-
60
-
61
- def _ancestor_directories(starting_file: Path) -> list[Path]:
62
- """Return *starting_file*'s directory and every parent, nearest first.
63
-
64
- ::
65
-
66
- repo/pkg/mod.py -> [repo/pkg, repo, ... , filesystem root]
67
- repo/pkg/ -> [repo/pkg, repo, ... , filesystem root]
68
-
69
- A file resolves to its containing directory; a directory resolves to
70
- itself, so both ancestor walks below start from the same first candidate.
71
-
72
- Args:
73
- starting_file: The file (or directory) the walk begins from.
74
-
75
- Returns:
76
- The resolved starting directory followed by each of its parents.
77
- """
78
- resolved_starting_file = starting_file.resolve()
79
- walk_origin = resolved_starting_file.parent if resolved_starting_file.is_file() else resolved_starting_file
80
- return [walk_origin, *walk_origin.parents]
81
-
82
-
83
66
  def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
84
67
  """Walk up from a starting file to locate a pyproject.toml that configures mypy.
85
68
 
@@ -97,12 +80,7 @@ def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
97
80
  table, or ``None`` when no such file exists between ``starting_file``
98
81
  and the filesystem root.
99
82
  """
100
- pyproject_filename_for_lookup = PYPROJECT_FILENAME
101
- for each_candidate_directory in _ancestor_directories(starting_file):
102
- candidate_pyproject = each_candidate_directory / pyproject_filename_for_lookup
103
- if candidate_pyproject.is_file() and _pyproject_contains_tool_mypy(candidate_pyproject):
104
- return candidate_pyproject
105
- return None
83
+ return find_pyproject_configuring_tool(starting_file, MYPY_TOOL_TABLE_NAME)
106
84
 
107
85
 
108
86
  def find_module_resolution_root(starting_file: Path) -> Path | None:
@@ -127,7 +105,7 @@ def find_module_resolution_root(starting_file: Path) -> Path | None:
127
105
  """
128
106
  git_entry_name = GIT_DIRECTORY_NAME
129
107
  pyproject_filename = PYPROJECT_FILENAME
130
- for each_candidate_directory in _ancestor_directories(starting_file):
108
+ for each_candidate_directory in ancestor_directories(starting_file):
131
109
  has_git_entry = (each_candidate_directory / git_entry_name).exists()
132
110
  has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
133
111
  if has_git_entry or has_pyproject:
@@ -171,7 +149,7 @@ def mypy_working_directory(all_py_files: list[str]) -> Iterator[str]:
171
149
  yield isolated_directory
172
150
 
173
151
 
174
- def _mypy_config_argument(all_py_files: list[str]) -> list[str]:
152
+ def _native_mypy_config_argument(all_py_files: list[str]) -> list[str]:
175
153
  """Return the ``--config-file`` argument for the first file with a mypy config."""
176
154
  for each_py_file in all_py_files:
177
155
  discovered_pyproject = find_pyproject_with_mypy_config(Path(each_py_file))
@@ -180,9 +158,39 @@ def _mypy_config_argument(all_py_files: list[str]) -> list[str]:
180
158
  return []
181
159
 
182
160
 
183
- def _run_mypy_subprocess(all_py_files: list[str]) -> subprocess.CompletedProcess[str]:
161
+ def _mypy_config_argument(
162
+ all_py_files: list[str], config_source_path: Path | None
163
+ ) -> list[str]:
164
+ """Return the ``--config-file`` argument, resolved from the original path when given.
165
+
166
+ ::
167
+
168
+ config_source_path resolves .../hooks/pyproject.toml
169
+ -> ["--config-file", ".../hooks/pyproject.toml"]
170
+ config_source_path given, no [tool.mypy] up-tree -> [] (native discovery)
171
+ config_source_path None -> native per-file discovery over all_py_files
172
+
173
+ Args:
174
+ all_py_files: The resolved paths mypy will check.
175
+ config_source_path: The original target path the staged copy stands in
176
+ for, or ``None`` for a native multi-file run.
177
+
178
+ Returns:
179
+ The ``--config-file`` argument vector, empty when no config resolves.
180
+ """
181
+ if config_source_path is None:
182
+ return _native_mypy_config_argument(all_py_files)
183
+ resolved_pyproject = find_pyproject_with_mypy_config(config_source_path)
184
+ if resolved_pyproject is not None:
185
+ return ["--config-file", str(resolved_pyproject)]
186
+ return _native_mypy_config_argument(all_py_files)
187
+
188
+
189
+ def _run_mypy_subprocess(
190
+ all_py_files: list[str], config_source_path: Path | None
191
+ ) -> subprocess.CompletedProcess[str]:
184
192
  """Run mypy over *all_py_files* from each file's own project root."""
185
- config_argument = _mypy_config_argument(all_py_files)
193
+ config_argument = _mypy_config_argument(all_py_files, config_source_path)
186
194
  with mypy_working_directory(all_py_files) as working_directory:
187
195
  return subprocess.run(
188
196
  ["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
@@ -194,24 +202,29 @@ def _run_mypy_subprocess(all_py_files: list[str]) -> subprocess.CompletedProcess
194
202
  )
195
203
 
196
204
 
197
- def run_mypy_check(files: list[Path]) -> MypyResult:
198
- """Run mypy on files."""
199
- if not files:
205
+ def run_mypy_check(
206
+ all_files: list[Path], config_source_path: Path | None = None
207
+ ) -> MypyResult:
208
+ """Run mypy on files, resolving config from *config_source_path* when given.
209
+
210
+ A given ``config_source_path`` walks ``--config-file`` up from the original
211
+ target rather than the staged copy's own ancestors.
212
+ """
213
+ if not all_files:
200
214
  return MypyResult(passed=True, output="No files to check", error_count=0)
201
215
 
202
216
  if not check_mypy_available():
203
217
  return MypyResult(passed=True, output="Mypy not installed - skipping", error_count=0)
204
218
 
205
- python_source_suffix = PYTHON_SOURCE_SUFFIX
206
219
  all_py_files = [
207
220
  str(each_file.resolve())
208
- for each_file in files
209
- if each_file.suffix == python_source_suffix
221
+ for each_file in all_files
222
+ if each_file.suffix == PYTHON_SOURCE_SUFFIX
210
223
  ]
211
224
  if not all_py_files:
212
225
  return MypyResult(passed=True, output="No Python files", error_count=0)
213
226
 
214
- completed_process = _run_mypy_subprocess(all_py_files)
227
+ completed_process = _run_mypy_subprocess(all_py_files, config_source_path)
215
228
  error_count = completed_process.stdout.count(": error:")
216
229
 
217
230
  return MypyResult(
@@ -0,0 +1,101 @@
1
+ """Resolve a validator's config file by walking up from an original path.
2
+
3
+ A staged temp copy has no config in its own ancestors, so ruff and mypy would
4
+ discover nothing there. This walk starts from the ORIGINAL target path and
5
+ returns the first pyproject.toml whose ``[tool.<name>]`` table exists.
6
+
7
+ ::
8
+
9
+ tool_table_name="ruff", start .../hooks/validators/module.py
10
+ -> climbs to .../hooks/pyproject.toml, which declares [tool.ruff] -> that path
11
+ tool_table_name="ruff", start .../advisor/scripts/module.py
12
+ -> that pyproject declares [tool.mypy] only, no [tool.ruff] -> None
13
+ """
14
+
15
+ import sys
16
+ import tomllib
17
+ from pathlib import Path
18
+
19
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
20
+
21
+ try:
22
+ from hooks_constants.mypy_integration_constants import PYPROJECT_FILENAME
23
+ from hooks_constants.pyproject_config_discovery_constants import TOOL_TABLE_KEY
24
+ except ModuleNotFoundError:
25
+ if _hooks_directory not in sys.path:
26
+ sys.path.insert(0, _hooks_directory)
27
+ from hooks_constants.mypy_integration_constants import PYPROJECT_FILENAME
28
+ from hooks_constants.pyproject_config_discovery_constants import TOOL_TABLE_KEY
29
+
30
+
31
+ def ancestor_directories(starting_file: Path) -> list[Path]:
32
+ """Return *starting_file*'s directory and every parent, nearest first.
33
+
34
+ ::
35
+
36
+ repo/pkg/mod.py -> [repo/pkg, repo, ... , filesystem root]
37
+ repo/pkg/ -> [repo/pkg, repo, ... , filesystem root]
38
+
39
+ A file resolves to its containing directory; a directory resolves to itself,
40
+ so both callers start the walk from the same first candidate.
41
+
42
+ Args:
43
+ starting_file: The file (or directory) the walk begins from.
44
+
45
+ Returns:
46
+ The resolved starting directory followed by each of its parents.
47
+ """
48
+ resolved_starting_file = starting_file.resolve()
49
+ walk_origin = (
50
+ resolved_starting_file.parent
51
+ if resolved_starting_file.is_file()
52
+ else resolved_starting_file
53
+ )
54
+ return [walk_origin, *walk_origin.parents]
55
+
56
+
57
+ def _pyproject_configures_tool(pyproject_path: Path, tool_table_name: str) -> bool:
58
+ """Return whether *pyproject_path* declares a ``[tool.<tool_table_name>]`` table.
59
+
60
+ A malformed or unreadable file counts as no match, so a broken pyproject
61
+ never shadows a valid config further up the tree.
62
+
63
+ Args:
64
+ pyproject_path: The candidate pyproject.toml to parse.
65
+ tool_table_name: The tool's table name below ``[tool]`` (for example ``ruff``).
66
+
67
+ Returns:
68
+ True when the parsed file holds ``[tool.<tool_table_name>]``, else False.
69
+ """
70
+ try:
71
+ with pyproject_path.open("rb") as readable_handle:
72
+ parsed_toml = tomllib.load(readable_handle)
73
+ except (OSError, tomllib.TOMLDecodeError):
74
+ return False
75
+ tool_table = parsed_toml.get(TOOL_TABLE_KEY, {})
76
+ return isinstance(tool_table, dict) and tool_table_name in tool_table
77
+
78
+
79
+ def find_pyproject_configuring_tool(starting_file: Path, tool_table_name: str) -> Path | None:
80
+ """Walk up from *starting_file* to the first pyproject.toml configuring a tool.
81
+
82
+ The walk skips a pyproject.toml that does not declare the tool's table, so an
83
+ unrelated package config does not shadow a config that actually configures
84
+ the tool further up the tree.
85
+
86
+ Args:
87
+ starting_file: The file (or directory) the walk begins from — the
88
+ original target path, not the staged temp copy.
89
+ tool_table_name: The tool's table name below ``[tool]`` (for example ``ruff``).
90
+
91
+ Returns:
92
+ The first ``pyproject.toml`` Path that declares ``[tool.<tool_table_name>]``,
93
+ or ``None`` when no such file exists up to the filesystem root.
94
+ """
95
+ for each_candidate_directory in ancestor_directories(starting_file):
96
+ candidate_pyproject = each_candidate_directory / PYPROJECT_FILENAME
97
+ if candidate_pyproject.is_file() and _pyproject_configures_tool(
98
+ candidate_pyproject, tool_table_name
99
+ ):
100
+ return candidate_pyproject
101
+ return None
@@ -1,9 +1,42 @@
1
1
  """Ruff integration for fast Python linting."""
2
2
 
3
+ import os
3
4
  import subprocess
5
+ import sys
4
6
  from dataclasses import dataclass
5
7
  from pathlib import Path
6
8
 
9
+ _validators_directory = str(Path(__file__).resolve().parent)
10
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
11
+
12
+ try:
13
+ from pyproject_config_discovery import find_pyproject_configuring_tool
14
+ except ModuleNotFoundError:
15
+ if _validators_directory not in sys.path:
16
+ sys.path.insert(0, _validators_directory)
17
+ from pyproject_config_discovery import find_pyproject_configuring_tool
18
+
19
+ try:
20
+ from hooks_constants.blocking_check_limits import RUFF_STDIN_ENCODING
21
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
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
+ )
28
+ except ModuleNotFoundError:
29
+ if _hooks_directory not in sys.path:
30
+ sys.path.insert(0, _hooks_directory)
31
+ from hooks_constants.blocking_check_limits import RUFF_STDIN_ENCODING
32
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
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
+ )
39
+
7
40
 
8
41
  @dataclass
9
42
  class RuffResult:
@@ -26,26 +59,195 @@ def check_ruff_available() -> bool:
26
59
  return False
27
60
 
28
61
 
29
- def run_ruff_check(files: list[Path]) -> RuffResult:
30
- """Run ruff check on files."""
31
- if not files:
32
- return RuffResult(passed=True, output="No files to check", fixed_count=0)
62
+ def _ruff_subprocess_environment() -> dict[str, str]:
63
+ """Return an environment that forces plain, non-ANSI ruff diagnostics.
33
64
 
34
- if not check_ruff_available():
35
- return RuffResult(passed=True, output="Ruff not installed - skipping", fixed_count=0)
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
+
75
+ def find_pyproject_with_ruff_config(starting_file: Path) -> Path | None:
76
+ """Walk up from a starting file to a pyproject.toml that configures ruff.
77
+
78
+ The walk skips a pyproject.toml with no [tool.ruff] table, so a mypy-only
79
+ config is not handed to ``ruff --config``, which errors on the missing table.
80
+
81
+ Args:
82
+ starting_file: The file (or directory) the walk begins from.
83
+
84
+ Returns:
85
+ The first ``pyproject.toml`` Path declaring a ``[tool.ruff]`` table, or
86
+ ``None`` when none exists up to the filesystem root.
87
+ """
88
+ return find_pyproject_configuring_tool(starting_file, RUFF_TOOL_TABLE_NAME)
89
+
90
+
91
+ def _ruff_config_argument(config_source_path: Path | None) -> list[str]:
92
+ """Return the ``--config`` argument resolved from the original path, or empty.
93
+
94
+ ::
95
+
96
+ config_source_path resolves .../hooks/pyproject.toml
97
+ -> ["--config", ".../hooks/pyproject.toml"]
98
+ config_source_path given, no [tool.ruff] up-tree -> [] (native discovery)
99
+ config_source_path None -> [] (native discovery)
100
+
101
+ Args:
102
+ config_source_path: The original target path the staged copy stands in
103
+ for, or ``None`` for a native run.
104
+
105
+ Returns:
106
+ The ``--config`` argument vector, empty when no ruff config resolves.
107
+ """
108
+ if config_source_path is None:
109
+ return []
110
+ resolved_pyproject = find_pyproject_with_ruff_config(config_source_path)
111
+ if resolved_pyproject is None:
112
+ return []
113
+ return ["--config", str(resolved_pyproject)]
114
+
115
+
116
+ def _config_relative_stdin_filename(target_path: Path, config_directory: Path) -> str:
117
+ """Return *target_path* expressed relative to the ruff config directory.
118
+
119
+ ::
120
+
121
+ target .../hooks/validators/run_all_validators.py, config dir .../hooks
122
+ -> "validators/run_all_validators.py" (path-scoped ignore matches)
123
+ target off-tree from the config dir -> the bare basename
124
+
125
+ Ruff matches ``[tool.ruff.lint.per-file-ignores]`` keys against the path a
126
+ file presents relative to ruff's working directory, so a path-scoped key
127
+ matches only when the staged copy is graded under that same relative name.
128
+
129
+ Args:
130
+ target_path: The real target path the staged copy stands in for.
131
+ config_directory: The directory holding the resolved ruff pyproject.
132
+
133
+ Returns:
134
+ The config-relative POSIX path when the target sits under the config
135
+ directory, else the target's basename.
136
+ """
137
+ try:
138
+ return target_path.relative_to(config_directory).as_posix()
139
+ except ValueError:
140
+ pass
141
+ resolved_target = target_path.resolve()
142
+ resolved_config_directory = config_directory.resolve()
143
+ try:
144
+ return resolved_target.relative_to(resolved_config_directory).as_posix()
145
+ except ValueError:
146
+ return resolved_target.name
147
+
148
+
149
+ def _read_staged_content(staged_file: Path) -> str | None:
150
+ """Return the staged copy's text content, or None when it cannot be read."""
151
+ try:
152
+ return staged_file.read_text(encoding="utf-8")
153
+ except OSError:
154
+ return None
155
+
156
+
157
+ def _single_staged_python_file(all_files: list[Path]) -> Path | None:
158
+ """Return the sole staged Python file, or None unless there is exactly one."""
159
+ all_python_files = [
160
+ each_file for each_file in all_files if each_file.suffix == PYTHON_SOURCE_SUFFIX
161
+ ]
162
+ if len(all_python_files) != 1:
163
+ return None
164
+ return all_python_files[0]
36
165
 
37
- py_files = [str(f) for f in files if f.suffix == ".py"]
38
- if not py_files:
39
- return RuffResult(passed=True, output="No Python files", fixed_count=0)
40
166
 
167
+ def _staged_ruff_command(resolved_pyproject: Path, stdin_filename: str) -> list[str]:
168
+ """Return the ruff argv that grades stdin under a config-relative name."""
41
169
  concise_output_arguments = ["--output-format", "concise"]
170
+ return [
171
+ "ruff",
172
+ "check",
173
+ "--config",
174
+ str(resolved_pyproject),
175
+ *concise_output_arguments,
176
+ "--stdin-filename",
177
+ stdin_filename,
178
+ "-",
179
+ ]
180
+
181
+
182
+ def _staged_ruff_result(
183
+ resolved_pyproject: Path, stdin_filename: str, staged_content: str
184
+ ) -> RuffResult:
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
+ """
42
195
  result = subprocess.run(
43
- ["ruff", "check", *concise_output_arguments] + py_files,
196
+ _staged_ruff_command(resolved_pyproject, stdin_filename),
44
197
  check=False,
45
198
  capture_output=True,
46
- text=True,
199
+ encoding=RUFF_STDIN_ENCODING,
200
+ errors="replace",
201
+ input=staged_content,
202
+ cwd=str(resolved_pyproject.parent),
203
+ env=_ruff_subprocess_environment(),
204
+ )
205
+ return RuffResult(
206
+ passed=result.returncode == 0,
207
+ output=result.stdout or result.stderr or "No issues found",
208
+ fixed_count=0,
47
209
  )
48
210
 
211
+
212
+ def _run_staged_ruff_check(
213
+ all_files: list[Path], config_source_path: Path
214
+ ) -> RuffResult | None:
215
+ """Grade one staged file under the config resolved from its original path.
216
+
217
+ The staged content is piped to ruff under ``--stdin-filename`` set to the
218
+ original target's config-relative name, so ruff applies path-scoped
219
+ per-file-ignores that never match the temporary copy's own location.
220
+ Returns None when the staged shape does not apply: no resolvable ruff
221
+ config, not exactly one Python file, or an unreadable staged copy.
222
+ """
223
+ resolved_pyproject = find_pyproject_with_ruff_config(config_source_path)
224
+ if resolved_pyproject is None:
225
+ return None
226
+ staged_file = _single_staged_python_file(all_files)
227
+ if staged_file is None:
228
+ return None
229
+ staged_content = _read_staged_content(staged_file)
230
+ if staged_content is None:
231
+ return None
232
+ stdin_filename = _config_relative_stdin_filename(
233
+ config_source_path, resolved_pyproject.parent
234
+ )
235
+ return _staged_ruff_result(resolved_pyproject, stdin_filename, staged_content)
236
+
237
+
238
+ def _run_native_ruff_check(
239
+ all_python_files: list[str], config_source_path: Path | None
240
+ ) -> RuffResult:
241
+ """Run ruff over *all_python_files* directly, resolving ``--config`` from the path."""
242
+ config_argument = _ruff_config_argument(config_source_path)
243
+ concise_output_arguments = ["--output-format", "concise"]
244
+ result = subprocess.run(
245
+ ["ruff", "check", *config_argument, *concise_output_arguments] + all_python_files,
246
+ check=False,
247
+ capture_output=True,
248
+ text=True,
249
+ env=_ruff_subprocess_environment(),
250
+ )
49
251
  return RuffResult(
50
252
  passed=result.returncode == 0,
51
253
  output=result.stdout or result.stderr or "No issues found",
@@ -53,32 +255,62 @@ def run_ruff_check(files: list[Path]) -> RuffResult:
53
255
  )
54
256
 
55
257
 
56
- def run_ruff_fix(files: list[Path]) -> RuffResult:
258
+ def run_ruff_check(
259
+ all_files: list[Path], config_source_path: Path | None = None
260
+ ) -> RuffResult:
261
+ """Run ruff check on *all_files*, resolving config from *config_source_path*.
262
+
263
+ A given ``config_source_path`` grades the single staged copy under the project
264
+ config resolved from the original target, applying its path-scoped
265
+ per-file-ignores. Without it, the files are checked natively."""
266
+ if not all_files:
267
+ return RuffResult(passed=True, output="No files to check", fixed_count=0)
268
+
269
+ if not check_ruff_available():
270
+ return RuffResult(passed=True, output="Ruff not installed - skipping", fixed_count=0)
271
+
272
+ all_python_files = [
273
+ str(each_file) for each_file in all_files if each_file.suffix == PYTHON_SOURCE_SUFFIX
274
+ ]
275
+ if not all_python_files:
276
+ return RuffResult(passed=True, output="No Python files", fixed_count=0)
277
+ if config_source_path is not None:
278
+ staged_result = _run_staged_ruff_check(all_files, config_source_path)
279
+ if staged_result is not None:
280
+ return staged_result
281
+ return _run_native_ruff_check(all_python_files, config_source_path)
282
+
283
+
284
+ def _parse_fixed_count(fix_output: str) -> int:
285
+ """Return the count ruff reports on its ``Fixed N`` line, or 0 when absent."""
286
+ for each_line in fix_output.split("\n"):
287
+ if "Fixed" not in each_line:
288
+ continue
289
+ try:
290
+ return int(each_line.split()[1])
291
+ except (IndexError, ValueError):
292
+ return 0
293
+ return 0
294
+
295
+
296
+ def run_ruff_fix(all_files: list[Path]) -> RuffResult:
57
297
  """Run ruff with --fix to auto-fix violations."""
58
298
  if not check_ruff_available():
59
299
  return RuffResult(passed=True, output="Ruff not installed", fixed_count=0)
60
-
61
- py_files = [str(f) for f in files if f.suffix == ".py"]
300
+ py_files = [
301
+ str(each_file) for each_file in all_files if each_file.suffix == PYTHON_SOURCE_SUFFIX
302
+ ]
62
303
  if not py_files:
63
304
  return RuffResult(passed=True, output="No Python files", fixed_count=0)
64
-
65
305
  result = subprocess.run(
66
306
  ["ruff", "check", "--fix"] + py_files,
67
307
  check=False,
68
308
  capture_output=True,
69
309
  text=True,
310
+ env=_ruff_subprocess_environment(),
70
311
  )
71
-
72
- fixed_count = 0
73
- for line in result.stdout.split("\n"):
74
- if "Fixed" in line:
75
- try:
76
- fixed_count = int(line.split()[1])
77
- except (IndexError, ValueError):
78
- pass
79
-
80
312
  return RuffResult(
81
313
  passed=result.returncode == 0,
82
314
  output=result.stdout or "No fixes applied",
83
- fixed_count=fixed_count,
315
+ fixed_count=_parse_fixed_count(result.stdout),
84
316
  )