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,8 +4,6 @@ import ast
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
-
9
7
  from .python_antipattern_checks import (
10
8
  check_mutable_default_args,
11
9
  check_bare_except,
@@ -198,16 +196,23 @@ def emit_status(message):
198
196
  '''
199
197
 
200
198
 
201
- def _antipattern_result_for(file_path: str, content: str) -> ValidatorResult:
202
- """Return the Python Anti-patterns result for content proposed at file_path."""
199
+ def _named_result(
200
+ file_path: str, content: str, validator_name: str
201
+ ) -> ValidatorResult:
202
+ """Return the named validator result for content proposed at file_path."""
203
203
  all_results = validate_proposed_file(file_path, content)
204
204
  return next(
205
205
  each_result
206
206
  for each_result in all_results
207
- if each_result.name == ANTIPATTERN_VALIDATOR_NAME
207
+ if each_result.name == validator_name
208
208
  )
209
209
 
210
210
 
211
+ def _antipattern_result_for(file_path: str, content: str) -> ValidatorResult:
212
+ """Return the Python Anti-patterns result for content proposed at file_path."""
213
+ return _named_result(file_path, content, ANTIPATTERN_VALIDATOR_NAME)
214
+
215
+
211
216
  class TestPrintCliExemptionThroughPipeline:
212
217
  """Pin the CLI-marker print exemption through the validate_proposed_file pipeline.
213
218
 
@@ -222,3 +227,103 @@ class TestPrintCliExemptionThroughPipeline:
222
227
  antipattern_result = _antipattern_result_for(CLI_BASENAME, PIPELINE_PRINT_SOURCE)
223
228
  assert antipattern_result.passed
224
229
  assert PRINT_FINDING_FRAGMENT not in antipattern_result.output
230
+
231
+
232
+ SCRIPTS_DIRECTORY_PATH = "packages/foo/scripts/run_job.py"
233
+ NON_EXEMPT_PRODUCTION_PATH = "packages/foo/services/worker.py"
234
+ TEST_FILE_PROPOSED_PATH = "packages/foo/tests/test_helpers.py"
235
+ USELESS_TEST_VALIDATOR_NAME = "Useless Tests"
236
+ USELESS_TEST_SOURCE = '''
237
+ def test_exists():
238
+ assert callable(str)
239
+ '''
240
+ TEST_HELPER_DIRECTORY_PATH = "packages/foo/tests/helper_functions.py"
241
+ NON_TEST_DIRECTORY_HELPER_PATH = "packages/foo/helpers/helper_functions.py"
242
+ CONFIG_DIRECTORY_MAGIC_PATH = "packages/foo/config/threshold_values.py"
243
+ CONFIG_CONSTANTS_DIRECTORY_MAGIC_PATH = (
244
+ "packages/foo/pr_converge_skill_constants/threshold_values.py"
245
+ )
246
+ NON_EXEMPT_MAGIC_PATH = "packages/foo/services/threshold_values.py"
247
+ MAGIC_VALUE_VALIDATOR_NAME = "Magic Values"
248
+ MAGIC_NUMBER_SOURCE = '''
249
+ def scale_amount(quantity):
250
+ return quantity * 199 * 42
251
+ '''
252
+
253
+
254
+ class TestDirectoryExemptionThroughPipeline:
255
+ """Directory-based exemptions must survive validate_proposed_file staging.
256
+
257
+ Basename-only temp staging drops /scripts/, so print() was falsely denied.
258
+ The real-path unit check allows the same content; the pipeline must match.
259
+ """
260
+
261
+ def test_scripts_directory_print_passes_through_pipeline(self) -> None:
262
+ tree = ast.parse(PIPELINE_PRINT_SOURCE)
263
+ basename_only = Path(SCRIPTS_DIRECTORY_PATH).name
264
+
265
+ assert len(check_print_in_production(tree, basename_only)) == 1
266
+ assert check_print_in_production(tree, SCRIPTS_DIRECTORY_PATH) == []
267
+
268
+ antipattern_result = _antipattern_result_for(
269
+ SCRIPTS_DIRECTORY_PATH, PIPELINE_PRINT_SOURCE
270
+ )
271
+ assert antipattern_result.passed
272
+ assert PRINT_FINDING_FRAGMENT not in antipattern_result.output
273
+
274
+ def test_non_exempt_production_print_still_fails_through_pipeline(self) -> None:
275
+ antipattern_result = _antipattern_result_for(
276
+ NON_EXEMPT_PRODUCTION_PATH, PIPELINE_PRINT_SOURCE
277
+ )
278
+ assert not antipattern_result.passed
279
+ assert PRINT_FINDING_FRAGMENT in antipattern_result.output
280
+
281
+ def test_test_filename_filtering_still_recognizes_test_path(self) -> None:
282
+ all_results = validate_proposed_file(TEST_FILE_PROPOSED_PATH, USELESS_TEST_SOURCE)
283
+ useless_result = next(
284
+ each_result
285
+ for each_result in all_results
286
+ if each_result.name == USELESS_TEST_VALIDATOR_NAME
287
+ )
288
+ assert not useless_result.passed
289
+ assert "callable" in useless_result.output.lower()
290
+
291
+ antipattern_result = _antipattern_result_for(
292
+ TEST_FILE_PROPOSED_PATH, TEST_FILE_WITH_PRINT
293
+ )
294
+ assert antipattern_result.passed
295
+ assert PRINT_FINDING_FRAGMENT not in antipattern_result.output
296
+
297
+ def test_tests_directory_recognized_without_test_basename(self) -> None:
298
+ exempt_result = _antipattern_result_for(
299
+ TEST_HELPER_DIRECTORY_PATH, PIPELINE_PRINT_SOURCE
300
+ )
301
+ assert exempt_result.passed
302
+ assert PRINT_FINDING_FRAGMENT not in exempt_result.output
303
+
304
+ flagged_result = _antipattern_result_for(
305
+ NON_TEST_DIRECTORY_HELPER_PATH, PIPELINE_PRINT_SOURCE
306
+ )
307
+ assert not flagged_result.passed
308
+ assert PRINT_FINDING_FRAGMENT in flagged_result.output
309
+
310
+ def test_config_directory_magic_value_exempt_through_pipeline(self) -> None:
311
+ exempt_result = _named_result(
312
+ CONFIG_DIRECTORY_MAGIC_PATH, MAGIC_NUMBER_SOURCE, MAGIC_VALUE_VALIDATOR_NAME
313
+ )
314
+ assert exempt_result.passed
315
+
316
+ denied_result = _named_result(
317
+ NON_EXEMPT_MAGIC_PATH, MAGIC_NUMBER_SOURCE, MAGIC_VALUE_VALIDATOR_NAME
318
+ )
319
+ assert not denied_result.passed
320
+
321
+ def test_config_exemption_survives_with_skill_constants_directory_ancestor(
322
+ self,
323
+ ) -> None:
324
+ exempt_result = _named_result(
325
+ CONFIG_CONSTANTS_DIRECTORY_MAGIC_PATH,
326
+ MAGIC_NUMBER_SOURCE,
327
+ MAGIC_VALUE_VALIDATOR_NAME,
328
+ )
329
+ assert exempt_result.passed
@@ -1,9 +1,52 @@
1
1
  """Tests for ruff integration module."""
2
2
 
3
+ import subprocess
4
+ import sys
3
5
  from pathlib import Path
4
6
  from unittest.mock import patch
5
7
 
6
- from .ruff_integration import RuffResult, check_ruff_available, run_ruff_check
8
+ from .ruff_integration import (
9
+ RuffResult,
10
+ _config_relative_stdin_filename,
11
+ _parse_fixed_count,
12
+ _staged_ruff_result,
13
+ check_ruff_available,
14
+ run_ruff_check,
15
+ )
16
+
17
+
18
+ def test_parse_fixed_count_reads_the_count_from_the_fixed_line() -> None:
19
+ """The ``Fixed N`` summary line yields its integer count."""
20
+ assert _parse_fixed_count("Found 3 errors (2 fixed).\nFixed 2 errors\n") == 2
21
+
22
+
23
+ def test_parse_fixed_count_returns_zero_without_a_fixed_line() -> None:
24
+ """Output with no ``Fixed`` line yields a zero count."""
25
+ assert _parse_fixed_count("All checks passed!\n") == 0
26
+
27
+
28
+ def test_config_relative_stdin_filename_returns_posix_path_under_config_directory() -> None:
29
+ """A target under the config directory resolves to its config-relative path."""
30
+ config_directory = Path("/repo/hooks")
31
+ target_path = Path("/repo/hooks/validators/run_all_validators.py")
32
+
33
+ relative_filename = _config_relative_stdin_filename(target_path, config_directory)
34
+
35
+ assert relative_filename == "validators/run_all_validators.py"
36
+
37
+
38
+ def test_config_relative_stdin_filename_falls_back_to_basename_off_tree() -> None:
39
+ """A target outside the config directory falls back to the bare basename.
40
+
41
+ Path-scoped per-file-ignores cannot apply to a file that does not sit under
42
+ the config directory, so a ``..``-laden relative path is never handed to ruff.
43
+ """
44
+ config_directory = Path("/repo/hooks")
45
+ target_path = Path("/elsewhere/run_all_validators.py")
46
+
47
+ relative_filename = _config_relative_stdin_filename(target_path, config_directory)
48
+
49
+ assert relative_filename == "run_all_validators.py"
7
50
 
8
51
 
9
52
  def test_ruff_result_dataclass() -> None:
@@ -24,9 +67,122 @@ def test_run_ruff_check_returns_passed_for_empty_files() -> None:
24
67
  """Test that run_ruff_check passes with no files."""
25
68
  result = run_ruff_check([])
26
69
  assert result.passed is True
70
+ assert result.fixed_count == 0
27
71
  assert "No files" in result.output
28
72
 
29
73
 
74
+ def test_run_ruff_check_applies_config_resolved_from_config_source_path(
75
+ tmp_path: Path,
76
+ ) -> None:
77
+ """A given config_source_path resolves the ruff config for a staged copy.
78
+
79
+ A ``[tool.ruff.lint]`` selecting B flags an ``assert False`` (B011) only when
80
+ ``config_source_path`` — the original ``.py`` target — resolves it; native
81
+ discovery from the detached staged copy finds no ruff config and passes.
82
+ """
83
+ ruff_repo = tmp_path / "ruff_repo"
84
+ ruff_repo.mkdir()
85
+ (ruff_repo / "pyproject.toml").write_text(
86
+ "[tool.ruff.lint]\nselect = ['B']\n", encoding="utf-8"
87
+ )
88
+ original_target = ruff_repo / "asserts.py"
89
+ staged_copy = tmp_path / "detached" / "asserts.py"
90
+ staged_copy.parent.mkdir(parents=True)
91
+ staged_copy.write_text("def probe() -> None:\n assert False\n", encoding="utf-8")
92
+
93
+ without_source = run_ruff_check([staged_copy])
94
+ with_source = run_ruff_check([staged_copy], config_source_path=original_target)
95
+
96
+ assert "B011" not in without_source.output
97
+ assert "B011" in with_source.output
98
+
99
+
100
+ def test_run_ruff_check_grades_a_staged_copy_holding_non_ascii_text(
101
+ tmp_path: Path,
102
+ ) -> None:
103
+ """A staged copy carrying non-ASCII text reaches ruff intact.
104
+
105
+ The staged shape pipes the copy's text to ruff on stdin, and ruff reads
106
+ stdin as UTF-8. Encoding that pipe with the host locale codec instead
107
+ hands ruff a byte it rejects, so every Python file holding a non-ASCII
108
+ character fails the gate on a host whose locale codec is not UTF-8.
109
+ """
110
+ ruff_repo = tmp_path / "ruff_repo"
111
+ ruff_repo.mkdir()
112
+ (ruff_repo / "pyproject.toml").write_text("[tool.ruff]\n", encoding="utf-8")
113
+ original_target = ruff_repo / "dashes.py"
114
+ staged_copy = tmp_path / "detached" / "dashes.py"
115
+ staged_copy.parent.mkdir(parents=True)
116
+ staged_copy.write_text('DASH_NOTE = "an em dash — here"\n', encoding="utf-8")
117
+
118
+ result = run_ruff_check([staged_copy], config_source_path=original_target)
119
+
120
+ assert result.passed is True
121
+
122
+
123
+ def test_staged_ruff_result_pins_utf8_encoding_with_tolerant_decode(
124
+ tmp_path: Path,
125
+ ) -> None:
126
+ """The staged ruff call pins UTF-8 and decodes ruff output tolerantly.
127
+
128
+ ::
129
+
130
+ subprocess.run(..., encoding="utf-8", errors="replace", ...)
131
+ ok: locale-independent stdin; a non-UTF-8 byte in ruff output never raises
132
+ flag: encoding omitted -> locale codec; errors omitted -> strict decode
133
+ raises out of subprocess.run on POSIX (fail-open in the gate)
134
+
135
+ The kwargs carry the contract host-independently. The locale fallback the
136
+ integration test above depends on cannot be forced portably, so this guard
137
+ asserts the call itself rather than the emergent behavior.
138
+ """
139
+ pyproject = tmp_path / "pyproject.toml"
140
+ pyproject.write_text("[tool.ruff]\n", encoding="utf-8")
141
+ captured_keyword_arguments: dict[str, object] = {}
142
+
143
+ def record_call(*positional: object, **keyword: object) -> subprocess.CompletedProcess[str]:
144
+ captured_keyword_arguments.update(keyword)
145
+ return subprocess.CompletedProcess([], 0, stdout="", stderr="")
146
+
147
+ with patch("subprocess.run", side_effect=record_call):
148
+ _staged_ruff_result(pyproject, "probe.py", "x = 1\n")
149
+
150
+ assert captured_keyword_arguments["encoding"] == "utf-8"
151
+ assert captured_keyword_arguments["errors"] == "replace"
152
+
153
+
154
+ def test_staged_ruff_result_tolerates_non_utf8_bytes_in_ruff_output(
155
+ tmp_path: Path,
156
+ ) -> None:
157
+ """A non-UTF-8 byte in ruff's output is decoded tolerantly, not dropped.
158
+
159
+ ::
160
+
161
+ ruff output carries byte 0x92 (a cp1252 curly quote, invalid UTF-8)
162
+ ok: errors="replace" -> output keeps the surrounding text
163
+ flag: errors="strict" -> POSIX raises out of run; Windows drops the
164
+ stream to None and the surrounding text is lost
165
+
166
+ Swaps the ruff argv for a python one-liner emitting the bad byte, so the
167
+ real subprocess decode path runs against real bytes.
168
+ """
169
+ pyproject = tmp_path / "pyproject.toml"
170
+ pyproject.write_text("[tool.ruff]\n", encoding="utf-8")
171
+ emit_bad_byte_command = [
172
+ sys.executable,
173
+ "-c",
174
+ "import sys; sys.stdout.buffer.write(bytes([0x62, 0x92, 0x63]))",
175
+ ]
176
+ staged_module = sys.modules[_staged_ruff_result.__module__]
177
+ with patch.object(
178
+ staged_module, "_staged_ruff_command", return_value=emit_bad_byte_command
179
+ ):
180
+ result = _staged_ruff_result(pyproject, "probe.py", "x = 1\n")
181
+
182
+ assert "b" in result.output
183
+ assert "c" in result.output
184
+
185
+
30
186
  def test_run_ruff_check_emits_location_prefixed_lines(tmp_path: Path) -> None:
31
187
  """Each reported violation carries a ``path:line:col:`` prefix on its own line.
32
188
 
@@ -38,7 +194,8 @@ def test_run_ruff_check_emits_location_prefixed_lines(tmp_path: Path) -> None:
38
194
  violating_file = tmp_path / "unused_import_module.py"
39
195
  violating_file.write_text("import os\n", encoding="utf-8")
40
196
 
41
- result = run_ruff_check([violating_file])
197
+ with patch.dict("os.environ", {"FORCE_COLOR": "1"}):
198
+ result = run_ruff_check([violating_file])
42
199
 
43
200
  assert result.passed is False
44
201
  location_prefix = f"{violating_file}:1:8:"
@@ -46,3 +203,4 @@ def test_run_ruff_check_emits_location_prefixed_lines(tmp_path: Path) -> None:
46
203
  each_line.startswith(location_prefix) and "F401" in each_line
47
204
  for each_line in result.output.splitlines()
48
205
  ), result.output
206
+ assert "\x1b[" not in result.output
@@ -8,9 +8,11 @@ from unittest.mock import MagicMock, patch
8
8
 
9
9
  import pytest
10
10
 
11
+ from .exempt_paths import is_test_file
11
12
  from .run_all_validators import (
12
13
  ValidatorResult,
13
14
  _hooks_subprocess_working_directory_and_environment,
15
+ _temporary_path_preserving_directory_signal,
14
16
  add_timing,
15
17
  build_json_output,
16
18
  create_timing_metrics,
@@ -20,8 +22,95 @@ from .run_all_validators import (
20
22
  run_git_checks,
21
23
  run_python_style_checks,
22
24
  run_with_fallback,
25
+ validate_proposed_file,
23
26
  )
24
27
 
28
+ _VALIDATORS_DIRECTORY = Path(__file__).parent
29
+ _ASSERT_FALSE_SOURCE = (
30
+ "def probe_condition(observed_total: int) -> None:\n"
31
+ " assert False, observed_total\n"
32
+ )
33
+ PRINT_PRODUCTION_CONTENT = 'def run() -> None:\n print("x")\n'
34
+ ALL_PYTHON_TEST_EXEMPT_PATHS = [
35
+ "pkg/test_helpers/worker.py",
36
+ "pkg/conftest.py",
37
+ "pkg/foo_test.py",
38
+ ]
39
+ ALL_NON_TEST_PRODUCTION_PATHS = [
40
+ "pkg/worker.py",
41
+ "pkg/latest/worker.py",
42
+ "pkg/protest/worker.py",
43
+ ]
44
+ ALL_IS_TEST_FILE_PARITY_PATHS = (
45
+ ALL_PYTHON_TEST_EXEMPT_PATHS
46
+ + ALL_NON_TEST_PRODUCTION_PATHS
47
+ + ["pkg/data.spec.helpers/worker.py"]
48
+ )
49
+
50
+
51
+ def test_validate_proposed_file_threads_ruff_config_from_original_path() -> None:
52
+ """A non-test staged file is linted under the project ruff config (B selected).
53
+
54
+ ::
55
+
56
+ original path .../validators/threading_probe.py, assert False body
57
+ ok: config resolved from the original path -> Ruff output carries B011
58
+ """
59
+ probe_path = _VALIDATORS_DIRECTORY / "threading_probe.py"
60
+
61
+ all_results = validate_proposed_file(str(probe_path), _ASSERT_FALSE_SOURCE)
62
+
63
+ ruff_output = next(
64
+ each_result.output for each_result in all_results if each_result.name == "Ruff"
65
+ )
66
+ assert "B011" in ruff_output
67
+
68
+
69
+ def _validators_deny_print(file_path: str) -> bool:
70
+ all_results = validate_proposed_file(file_path, PRINT_PRODUCTION_CONTENT)
71
+ return any(not each_result.passed for each_result in all_results)
72
+
73
+
74
+ @pytest.mark.parametrize("file_path", ALL_PYTHON_TEST_EXEMPT_PATHS)
75
+ def test_staging_keeps_substring_test_exemption(file_path: str) -> None:
76
+ assert is_test_file(file_path) is True
77
+ assert _validators_deny_print(file_path) is False
78
+
79
+
80
+ @pytest.mark.parametrize("file_path", ALL_NON_TEST_PRODUCTION_PATHS)
81
+ def test_staging_leaves_non_test_paths_denied(file_path: str) -> None:
82
+ assert is_test_file(file_path) is False
83
+ assert _validators_deny_print(file_path) is True
84
+
85
+
86
+ @pytest.mark.parametrize("file_path", ALL_IS_TEST_FILE_PARITY_PATHS)
87
+ def test_staged_path_matches_is_test_file_verdict(
88
+ file_path: str, tmp_path: Path
89
+ ) -> None:
90
+ staged_path = _temporary_path_preserving_directory_signal(tmp_path, file_path)
91
+ staged_relative_path = staged_path.relative_to(tmp_path)
92
+ assert is_test_file(str(staged_relative_path)) == is_test_file(file_path)
93
+
94
+
95
+ def _passing_mock_result() -> MagicMock:
96
+ """Return a mock validator result that reports a passing check."""
97
+ mock_result = MagicMock()
98
+ mock_result.passed = True
99
+ mock_result.name = "Test"
100
+ mock_result.checks = "test"
101
+ mock_result.output = ""
102
+ return mock_result
103
+
104
+
105
+ def _run_main_with_argv(command_line_arguments: list[str]) -> int | None:
106
+ """Run ``main`` under the given argv and always restore the original argv."""
107
+ original_argv = sys.argv
108
+ try:
109
+ sys.argv = command_line_arguments
110
+ return main()
111
+ finally:
112
+ sys.argv = original_argv
113
+
25
114
 
26
115
  class TestFixFlag:
27
116
  """Test --fix flag functionality."""
@@ -31,25 +120,11 @@ class TestFixFlag:
31
120
  with patch("validators.run_all_validators.get_changed_files") as mock_get_files, \
32
121
  patch("validators.run_all_validators.run_file_structure_checks") as mock_file, \
33
122
  patch("validators.run_all_validators.run_git_checks") as mock_git:
34
-
35
123
  mock_get_files.return_value = []
124
+ mock_file.return_value = _passing_mock_result()
125
+ mock_git.return_value = _passing_mock_result()
36
126
 
37
- mock_result = MagicMock()
38
- mock_result.passed = True
39
- mock_result.name = "Test"
40
- mock_result.checks = "test"
41
- mock_result.output = ""
42
-
43
- mock_file.return_value = mock_result
44
- mock_git.return_value = mock_result
45
-
46
- original_argv = sys.argv
47
- try:
48
- sys.argv = ["run_all_validators.py", "--fix"]
49
- result = main()
50
- assert result == 0
51
- finally:
52
- sys.argv = original_argv
127
+ assert _run_main_with_argv(["run_all_validators.py", "--fix"]) == 0
53
128
 
54
129
  def test_fix_flag_calls_fix_python_style(self) -> None:
55
130
  """Verify --fix flag triggers fix_python_style when files exist."""
@@ -61,29 +136,12 @@ class TestFixFlag:
61
136
  patch("validators.run_all_validators.run_comment_checks") as mock_comment, \
62
137
  patch("validators.run_all_validators.run_file_structure_checks") as mock_file, \
63
138
  patch("validators.run_all_validators.run_git_checks") as mock_git:
64
-
65
139
  mock_get_files.return_value = [Path("test.py")]
66
140
  mock_fix.return_value = ["test.py"]
141
+ for each_mock in (mock_style, mock_test, mock_react, mock_comment, mock_file, mock_git):
142
+ each_mock.return_value = _passing_mock_result()
67
143
 
68
- mock_result = MagicMock()
69
- mock_result.passed = True
70
- mock_result.name = "Test"
71
- mock_result.checks = "test"
72
- mock_result.output = ""
73
-
74
- mock_style.return_value = mock_result
75
- mock_test.return_value = mock_result
76
- mock_react.return_value = mock_result
77
- mock_comment.return_value = mock_result
78
- mock_file.return_value = mock_result
79
- mock_git.return_value = mock_result
80
-
81
- original_argv = sys.argv
82
- try:
83
- sys.argv = ["run_all_validators.py", "--fix"]
84
- main()
85
- finally:
86
- sys.argv = original_argv
144
+ _run_main_with_argv(["run_all_validators.py", "--fix"])
87
145
 
88
146
  mock_fix.assert_called_once()
89
147
 
@@ -97,59 +155,71 @@ class TestFixFlag:
97
155
  patch("validators.run_all_validators.run_comment_checks") as mock_comment, \
98
156
  patch("validators.run_all_validators.run_file_structure_checks") as mock_file, \
99
157
  patch("validators.run_all_validators.run_git_checks") as mock_git:
100
-
101
158
  mock_get_files.return_value = [Path("test.py")]
159
+ for each_mock in (mock_style, mock_test, mock_react, mock_comment, mock_file, mock_git):
160
+ each_mock.return_value = _passing_mock_result()
102
161
 
103
- mock_result = MagicMock()
104
- mock_result.passed = True
105
- mock_result.name = "Test"
106
- mock_result.checks = "test"
107
- mock_result.output = ""
108
-
109
- mock_style.return_value = mock_result
110
- mock_test.return_value = mock_result
111
- mock_react.return_value = mock_result
112
- mock_comment.return_value = mock_result
113
- mock_file.return_value = mock_result
114
- mock_git.return_value = mock_result
115
-
116
- original_argv = sys.argv
117
- try:
118
- sys.argv = ["run_all_validators.py"]
119
- main()
120
- finally:
121
- sys.argv = original_argv
162
+ _run_main_with_argv(["run_all_validators.py"])
122
163
 
123
164
  mock_fix.assert_not_called()
124
165
 
125
166
 
167
+ class TestStagedPathContainment:
168
+ """Staged validation paths must never escape the temporary root."""
169
+
170
+ def test_parent_traversal_path_stays_inside_temp_root(self, tmp_path: Path) -> None:
171
+ temporary_root = tmp_path / "staging"
172
+ temporary_root.mkdir()
173
+ traversal_path = "scripts/../../../../escaped_evil.py"
174
+
175
+ staged_file = _temporary_path_preserving_directory_signal(
176
+ temporary_root, traversal_path
177
+ )
178
+ staged_file.write_text("payload", encoding="utf-8")
179
+
180
+ assert staged_file.resolve().is_relative_to(temporary_root.resolve())
181
+ assert staged_file.read_text(encoding="utf-8") == "payload"
182
+
183
+ def test_absolute_component_path_stays_inside_temp_root(self, tmp_path: Path) -> None:
184
+ temporary_root = tmp_path / "staging"
185
+ temporary_root.mkdir()
186
+ absolute_style_path = "/etc/cron.d/evil.py"
187
+
188
+ staged_file = _temporary_path_preserving_directory_signal(
189
+ temporary_root, absolute_style_path
190
+ )
191
+ staged_file.write_text("payload", encoding="utf-8")
192
+
193
+ assert staged_file.resolve().is_relative_to(temporary_root.resolve())
194
+
195
+
126
196
  class TestGracefulDegradation:
127
197
  def test_missing_validator_returns_skipped_result(self) -> None:
128
198
  def failing_validator() -> ValidatorResult:
129
199
  raise FileNotFoundError("validator.py not found")
130
200
 
131
- result = run_with_fallback(
201
+ skipped_result = run_with_fallback(
132
202
  failing_validator,
133
203
  "Missing Validator",
134
204
  "99",
135
205
  )
136
206
 
137
- assert result.skipped is True
138
- assert "skipped" in result.output.lower()
139
- assert result.passed is False
207
+ assert skipped_result.skipped is True
208
+ assert "skipped" in skipped_result.output.lower()
209
+ assert skipped_result.passed is False
140
210
 
141
211
  def test_validator_exception_returns_skipped_result(self) -> None:
142
212
  def crashing_validator() -> ValidatorResult:
143
213
  raise RuntimeError("Unexpected crash")
144
214
 
145
- result = run_with_fallback(
215
+ skipped_result = run_with_fallback(
146
216
  crashing_validator,
147
217
  "Crashing Validator",
148
218
  "99",
149
219
  )
150
220
 
151
- assert result.skipped is True
152
- assert "skipped" in result.output.lower()
221
+ assert skipped_result.skipped is True
222
+ assert "skipped" in skipped_result.output.lower()
153
223
 
154
224
  def test_successful_validator_returns_normal_result(self) -> None:
155
225
  def working_validator() -> ValidatorResult:
@@ -160,14 +230,14 @@ class TestGracefulDegradation:
160
230
  output="All good",
161
231
  )
162
232
 
163
- result = run_with_fallback(
233
+ normal_result = run_with_fallback(
164
234
  working_validator,
165
235
  "Working",
166
236
  "1",
167
237
  )
168
238
 
169
- assert result.skipped is False
170
- assert result.passed is True
239
+ assert normal_result.skipped is False
240
+ assert normal_result.passed is True
171
241
 
172
242
 
173
243
  class TestStderrSurfacing:
@@ -244,7 +314,9 @@ class TestTimingMetrics:
244
314
 
245
315
 
246
316
  class TestVersionHeader:
247
- def test_print_header_includes_version(self, capsys) -> None:
317
+ def test_print_header_includes_version(
318
+ self, capsys: pytest.CaptureFixture[str]
319
+ ) -> None:
248
320
  print_header()
249
321
  captured = capsys.readouterr()
250
322