claude-dev-env 2.4.0 → 2.7.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 (309) hide show
  1. package/CLAUDE.md +26 -59
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  13. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  14. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  15. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  16. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  18. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  19. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  20. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  21. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  22. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  23. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  24. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  25. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  26. package/_shared/pr-loop/worker-spawn.md +1 -1
  27. package/agents/CLAUDE.md +3 -2
  28. package/agents/caveman.md +0 -1
  29. package/agents/clasp-deployment-orchestrator.md +0 -1
  30. package/agents/clean-coder.md +0 -1
  31. package/agents/code-advisor.md +0 -1
  32. package/agents/code-quality-agent.md +1 -2
  33. package/agents/code-verifier.md +36 -8
  34. package/agents/deep-research.md +0 -1
  35. package/agents/docs-agent.md +0 -1
  36. package/agents/git-commit-crafter.md +0 -1
  37. package/agents/issue-tracker.md +42 -0
  38. package/agents/plan-packet-validator.md +0 -1
  39. package/agents/pr-description-writer.md +0 -1
  40. package/agents/test_agent_frontmatter.py +67 -18
  41. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  42. package/bin/CLAUDE.md +68 -5
  43. package/bin/codex-compat.mjs +104 -0
  44. package/bin/codex-compat.test.mjs +51 -0
  45. package/bin/ever-shipped-skills.mjs +1 -0
  46. package/bin/install-constants.mjs +88 -0
  47. package/bin/install.mjs +1138 -114
  48. package/bin/install.prune.test.mjs +869 -19
  49. package/bin/install.test.mjs +906 -2
  50. package/codex-capability-map.json +13 -0
  51. package/commands/implement.md +1 -1
  52. package/commands/right-size.md +1 -1
  53. package/docs/CLAUDE.md +1 -0
  54. package/docs/CODE_RULES.md +2 -0
  55. package/docs/codex-compatibility.md +25 -0
  56. package/docs/host-pool-health-monitor.md +102 -0
  57. package/docs/nas-ssh-invocation.md +96 -12
  58. package/docs/references/CLAUDE.md +4 -2
  59. package/docs/references/advisor-tool.md +13 -0
  60. package/docs/references/code-review-enforcement.md +35 -0
  61. package/docs/references/team-advisor-skill.md +14 -0
  62. package/hooks/blocking/CLAUDE.md +4 -0
  63. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  64. package/hooks/blocking/code_review_push_gate.py +9 -4
  65. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  66. package/hooks/blocking/config/__init__.py +5 -5
  67. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
  68. package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
  69. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  70. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  71. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  72. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  73. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  74. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  75. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  76. package/hooks/blocking/state_description_blocker.py +1 -1
  77. package/hooks/blocking/stop_dispatcher.py +1 -1
  78. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  79. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  80. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  81. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  82. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  83. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  84. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  85. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  86. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  87. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  88. package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
  89. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  90. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  91. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  92. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  93. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  94. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  95. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  96. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  97. package/hooks/git-hooks/CLAUDE.md +3 -0
  98. package/hooks/git-hooks/conftest.py +30 -0
  99. package/hooks/git-hooks/gate_utils.py +2 -2
  100. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  101. package/hooks/git-hooks/pre_push.py +75 -4
  102. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  103. package/hooks/git-hooks/test_config.py +0 -15
  104. package/hooks/git-hooks/test_gate_utils.py +3 -15
  105. package/hooks/git-hooks/test_pre_commit.py +1 -15
  106. package/hooks/git-hooks/test_pre_push.py +257 -23
  107. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  108. package/hooks/hooks.json +10 -12
  109. package/hooks/hooks_constants/CLAUDE.md +7 -2
  110. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  111. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  112. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  113. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  114. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  115. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  116. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  117. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  118. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  119. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  120. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  121. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  122. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  123. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  124. package/hooks/pyproject.toml +1 -0
  125. package/hooks/validators/CLAUDE.md +1 -0
  126. package/hooks/validators/config/__init__.py +0 -0
  127. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  128. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  129. package/hooks/validators/conftest.py +4 -0
  130. package/hooks/validators/ruff_integration.py +49 -5
  131. package/hooks/validators/run_all_validators.py +206 -9
  132. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +92 -1
  135. package/hooks/validators/test_run_all_validators.py +115 -68
  136. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  137. package/package.json +13 -3
  138. package/rules/CLAUDE.md +17 -22
  139. package/rules/agent-spawn-protocol.md +6 -6
  140. package/rules/anti-corollary-tests.md +1 -1
  141. package/rules/bdd.md +1 -1
  142. package/rules/cleanup-temp-files.md +10 -4
  143. package/rules/code-standards.md +7 -0
  144. package/rules/conservative-action.md +1 -5
  145. package/rules/context7.md +0 -4
  146. package/rules/destructive-commands.md +47 -0
  147. package/rules/doc-inventory-integrity.md +48 -0
  148. package/rules/doc-prose-cuts.md +58 -0
  149. package/rules/docstring-prose-matches-implementation.md +53 -44
  150. package/rules/durable-post-artifacts.md +0 -4
  151. package/rules/eli11-replies.md +31 -0
  152. package/rules/explore-thoroughly.md +4 -4
  153. package/rules/falsify-before-green.md +68 -0
  154. package/rules/file-global-constants.md +1 -1
  155. package/rules/filesystem-search.md +51 -0
  156. package/rules/gh-cli-conventions.md +27 -0
  157. package/rules/git-workflow.md +26 -0
  158. package/rules/hedging-claims.md +9 -0
  159. package/rules/long-horizon-autonomy.md +0 -4
  160. package/rules/measurement-denominators.md +48 -0
  161. package/rules/nas-ssh-invocation.md +23 -5
  162. package/rules/parallel-tools.md +2 -2
  163. package/rules/plain-illustrative-docstrings.md +3 -7
  164. package/rules/plain-language.md +2 -0
  165. package/rules/proof-of-work-pr-comments.md +0 -4
  166. package/rules/re-stage-before-commit.md +2 -0
  167. package/rules/research-mode.md +10 -0
  168. package/rules/shell-invocation.md +21 -0
  169. package/rules/testing.md +4 -0
  170. package/rules/verified-commit-gate-skip.md +3 -27
  171. package/rules/verify-before-asking.md +5 -0
  172. package/rules/windows-filesystem-safe.md +1 -1
  173. package/rules/workers-done-before-complete.md +4 -0
  174. package/scripts/CLAUDE.md +1 -0
  175. package/scripts/Capture-PoolHealth.ps1 +410 -0
  176. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  177. package/scripts/_code_review_test_support.py +404 -0
  178. package/scripts/claude_chain_runner.py +141 -1
  179. package/scripts/codex_capability_bridge.py +171 -0
  180. package/scripts/codex_compat_materializer.py +1087 -0
  181. package/scripts/codex_compat_watcher.py +502 -0
  182. package/scripts/conftest.py +16 -1
  183. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  184. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  185. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  186. package/scripts/invoke_code_review.py +11 -4
  187. package/scripts/resolve_worker_spawn.py +626 -626
  188. package/scripts/spawn_grok_batch.py +672 -672
  189. package/scripts/sync_to_cursor/rules.py +0 -10
  190. package/scripts/test_claude_chain_runner.py +131 -0
  191. package/scripts/test_invoke_code_review.py +85 -908
  192. package/scripts/test_invoke_code_review_chain.py +70 -0
  193. package/scripts/test_invoke_code_review_cli.py +192 -0
  194. package/scripts/test_invoke_code_review_contract.py +256 -0
  195. package/scripts/test_invoke_code_review_git.py +123 -0
  196. package/scripts/test_invoke_code_review_mode.py +99 -0
  197. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  198. package/scripts/tests/test_code_review_constants.py +80 -0
  199. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  200. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  201. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  202. package/scripts/tests/test_sync_to_cursor.py +0 -1
  203. package/skills/CLAUDE.md +2 -0
  204. package/skills/auditing-claude-config/SKILL.md +114 -114
  205. package/skills/autoconverge/SKILL.md +427 -427
  206. package/skills/autoconverge/reference/convergence.md +24 -3
  207. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  208. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  209. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  210. package/skills/autoconverge/workflow/converge.mjs +168 -1
  211. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  212. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  213. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  214. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  215. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  216. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  217. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  218. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  219. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  220. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  221. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  222. package/skills/closeout/SKILL.md +33 -50
  223. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  224. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  225. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  226. package/skills/condensing-instructions/SKILL.md +72 -0
  227. package/skills/copilot-review/SKILL.md +119 -119
  228. package/skills/e-code-review/SKILL.md +52 -0
  229. package/skills/e-code-review/reference/fix.md +54 -0
  230. package/skills/e-code-review/reference/loop.md +43 -0
  231. package/skills/e-code-review/reference/low.md +57 -0
  232. package/skills/e-code-review/reference/medium.md +153 -0
  233. package/skills/e-code-review/reference/xhigh.md +182 -0
  234. package/skills/e-simplify/SKILL.md +97 -0
  235. package/skills/fresh-branch/CLAUDE.md +1 -1
  236. package/skills/fresh-branch/SKILL.md +5 -6
  237. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  238. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  239. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  240. package/skills/issue-tracker/SKILL.md +92 -0
  241. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  242. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  243. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  244. package/skills/orchestrator/SKILL.md +177 -22
  245. package/skills/orchestrator/scripts/status_gate.py +625 -0
  246. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  247. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  248. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  249. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  250. package/skills/orchestrator-refresh/SKILL.md +129 -35
  251. package/skills/plan-to-pr/SKILL.md +155 -0
  252. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  253. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  254. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  255. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  256. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  257. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  258. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  259. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  260. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  261. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  262. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  263. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  264. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  265. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  266. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  267. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  268. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  269. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  270. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  271. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  272. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  273. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  274. package/skills/pr-converge/SKILL.md +472 -469
  275. package/skills/pr-converge/reference/examples.md +3 -3
  276. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  277. package/skills/pr-converge/reference/ground-rules.md +7 -4
  278. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  279. package/skills/pr-converge/reference/per-tick.md +5 -5
  280. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  281. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  282. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  283. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  284. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  285. package/skills/pr-fix-protocol/SKILL.md +1 -1
  286. package/skills/privacy-hygiene/SKILL.md +68 -68
  287. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  288. package/skills/prototype/workflows/promotion.md +1 -1
  289. package/skills/release-notes-html/SKILL.md +164 -0
  290. package/skills/session-log/SKILL.md +1 -1
  291. package/skills/task-build/CLAUDE.md +8 -7
  292. package/skills/task-build/SKILL.md +16 -8
  293. package/skills/task-build/reference/tool-routing.md +19 -0
  294. package/rules/claude-md-orphan-file.md +0 -28
  295. package/rules/cleanup-command-forms.md +0 -23
  296. package/rules/code-reviews.md +0 -11
  297. package/rules/env-var-table-code-drift.md +0 -10
  298. package/rules/gh-body-file.md +0 -5
  299. package/rules/gh-paginate.md +0 -3
  300. package/rules/hook-prose-matches-detector.md +0 -15
  301. package/rules/no-historical-clutter.md +0 -26
  302. package/rules/no-inline-destructive-literals.md +0 -9
  303. package/rules/no-justification-noise.md +0 -61
  304. package/rules/package-inventory-stale-entry.md +0 -25
  305. package/rules/right-sized-engineering.md +0 -28
  306. package/rules/self-contained-docs.md +0 -17
  307. package/rules/shell-invocation-policy.md +0 -5
  308. package/rules/tdd.md +0 -7
  309. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,13 +1,33 @@
1
- """Run the staged Python test files, grouped by their owning pytest config.
1
+ """Run the staged Python test files, grouped by owning pytest config or top-level directory.
2
2
 
3
3
  The commit gate runs each staged test file so a broken test blocks the commit.
4
- Files are grouped by the nearest ancestor holding a pytest config, and each
5
- group runs in its own pytest session with the working directory at that root, so
6
- two packages exposing a same-named top-level package never shadow each other.
4
+ Files are grouped by the nearest ancestor holding a pytest config, falling back
5
+ to the file's own top-level directory under the repository root when no ancestor
6
+ owns one. Each group runs in its own pytest session with the working directory
7
+ at that root, so two packages exposing a same-named top-level package never
8
+ shadow each other.
7
9
 
8
10
  ``conftest.py`` paths are never passed as pytest collection targets: pytest loads
9
11
  them automatically when nearby tests run, and collecting multiple bare confests
10
12
  in one session collides on the shared module basename.
13
+
14
+ ``staged_test_regression`` is the commit gate's entry point: it discovers the
15
+ staged test files, groups them by owning pytest-config root, and calls back
16
+ into this module's group-running primitives to run each group (optionally
17
+ with a JUnit XML report per batch, for its staged-vs-baseline comparison).
18
+
19
+ Boundaries of the top-level fallback, which fires only in a repository whose
20
+ root holds no pytest config::
21
+
22
+ repository_root = /repo, holding no pytest config of its own
23
+ ok: /repo/alpha/test_a.py, /repo/beta/test_b.py -> two sessions
24
+ flag: /repo/pkgs/alpha/test_a.py, /repo/pkgs/beta/test_b.py -> one session
25
+
26
+ Config-less packages nested under one shared top-level directory run in that
27
+ directory's single session, so the split separates top-level neighbors alone.
28
+ A repository-root ``conftest.py`` stays unloaded for those groups as well: the
29
+ session works from the top-level directory, which leaves the root file outside
30
+ the tree pytest treats as its root.
11
31
  """
12
32
 
13
33
  import os
@@ -24,12 +44,11 @@ from pr_loop_shared_constants.code_rules_gate_constants import (
24
44
  CODE_RULES_GATE_PYTHON_ENV_VAR,
25
45
  CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
26
46
  COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
47
+ JUNIT_XML_FLAG_PREFIX,
27
48
  MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
28
49
  PYTHON_FILE_EXTENSION,
29
50
  PYTHONPATH_ENV_VAR,
30
51
  STAGED_PYTEST_TIMEOUT_SECONDS,
31
- STAGED_TEST_FAILURE_HEADER,
32
- STAGED_TEST_GROUP_FAILURE_MESSAGE,
33
52
  TEST_CONFTEST_FILENAME,
34
53
  )
35
54
  from pr_loop_shared_constants.preflight_constants import (
@@ -102,6 +121,36 @@ def _directory_holds_pytest_config(directory: Path) -> bool:
102
121
  return False
103
122
 
104
123
 
124
+ def _walk_to_owning_test_root(
125
+ resolved_test_file_path: Path, resolved_repository_root: Path
126
+ ) -> tuple[Path, bool]:
127
+ """Walk up once for the owning test root and whether that root holds a config.
128
+
129
+ ::
130
+
131
+ resolved_repository_root = /repo, holding no pytest config of its own
132
+ ok: /repo/pkg/pytest.ini above /repo/pkg/suite/test_a.py -> (/repo/pkg, True)
133
+ ok: /repo/tools/tests/test_b.py -> (/repo, False)
134
+
135
+ One walk answers both questions a caller has, so the repository root's
136
+ config is read once per staged file rather than once per question.
137
+
138
+ Args:
139
+ resolved_test_file_path: The already-resolved staged test file.
140
+ resolved_repository_root: The already-resolved repository root.
141
+
142
+ Returns:
143
+ The nearest config-owning ancestor paired with True, or the repository
144
+ root paired with False when no ancestor up to it owns a config.
145
+ """
146
+ for each_ancestor in resolved_test_file_path.parents:
147
+ if _directory_holds_pytest_config(each_ancestor):
148
+ return each_ancestor, True
149
+ if each_ancestor == resolved_repository_root:
150
+ return resolved_repository_root, False
151
+ return resolved_repository_root, False
152
+
153
+
105
154
  def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
106
155
  """Return the nearest ancestor of *test_file_path* owning a pytest config.
107
156
 
@@ -112,31 +161,107 @@ def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Pa
112
161
  Returns:
113
162
  The resolved owning test root directory.
114
163
  """
164
+ owning_root, _has_owning_pytest_config = _walk_to_owning_test_root(
165
+ test_file_path.resolve(), repository_root.resolve()
166
+ )
167
+ return owning_root
168
+
169
+
170
+ def _resolved_path_or_none(each_path: Path) -> Path | None:
171
+ """Return *each_path* resolved, or None when the filesystem refuses to resolve it."""
172
+ try:
173
+ return each_path.resolve()
174
+ except OSError:
175
+ return None
176
+
177
+
178
+ def _top_level_group_root(
179
+ resolved_test_file_path: Path, resolved_repository_root: Path
180
+ ) -> Path:
181
+ """Return the repository top-level directory *resolved_test_file_path* sits under.
182
+
183
+ ::
184
+
185
+ resolved_repository_root = /repo
186
+ ok: /repo/tools/tests/test_a.py -> /repo/tools
187
+ ok: /repo/test_b.py -> /repo
188
+ ok: /elsewhere/test_c.py -> /repo (outside the repository)
189
+
190
+ Args:
191
+ resolved_test_file_path: The already-resolved staged test file to place
192
+ under a top-level directory.
193
+ resolved_repository_root: The already-resolved repository root.
194
+
195
+ Returns:
196
+ The repository root joined to the file's first repository-relative path
197
+ component; the repository root itself for a file sitting directly at it
198
+ or outside it.
199
+ """
200
+ try:
201
+ repository_relative_path = resolved_test_file_path.relative_to(resolved_repository_root)
202
+ except ValueError:
203
+ return resolved_repository_root
204
+ all_relative_parts = repository_relative_path.parts
205
+ if len(all_relative_parts) <= 1:
206
+ return resolved_repository_root
207
+ return resolved_repository_root / all_relative_parts[0]
208
+
209
+
210
+ def _group_root_for_test_file(test_file_path: Path, repository_root: Path) -> Path:
211
+ """Return the pytest session root *test_file_path* runs under.
212
+
213
+ ::
214
+
215
+ repository_root = /repo, holding no pytest config of its own
216
+ ok: /repo/pkg/pytest.ini next to /repo/pkg/suite/test_a.py -> /repo/pkg
217
+ ok: /repo/tools/tests/test_b.py -> /repo/tools
218
+ ok: /repo/test_c.py -> /repo
219
+ flag: /repo/tools/... and /repo/pipeline/... in one /repo bucket
220
+
221
+ A config-owning ancestor always wins. A file no ancestor config owns falls
222
+ back to its top-level directory, so two config-less packages exposing a
223
+ same-named top-level module run in separate pytest processes rather than
224
+ shadowing each other in one. A staged path the filesystem refuses to
225
+ resolve joins the repository-root group instead of aborting the gate.
226
+
227
+ Args:
228
+ test_file_path: The staged test file to place in a group.
229
+ repository_root: The repository root that bounds the upward walk.
230
+
231
+ Returns:
232
+ The config-owning ancestor when one exists, the repository root when the
233
+ root owns the config or the path fails to resolve, else the file's
234
+ top-level directory.
235
+ """
115
236
  resolved_repository_root = repository_root.resolve()
116
- for each_ancestor in test_file_path.resolve().parents:
117
- if _directory_holds_pytest_config(each_ancestor):
118
- return each_ancestor
119
- if each_ancestor == resolved_repository_root:
120
- return resolved_repository_root
121
- return resolved_repository_root
237
+ resolved_test_file_path = _resolved_path_or_none(test_file_path)
238
+ if resolved_test_file_path is None:
239
+ return resolved_repository_root
240
+ owning_root, has_owning_pytest_config = _walk_to_owning_test_root(
241
+ resolved_test_file_path, resolved_repository_root
242
+ )
243
+ if has_owning_pytest_config:
244
+ return owning_root
245
+ return _top_level_group_root(resolved_test_file_path, resolved_repository_root)
122
246
 
123
247
 
124
248
  def _group_staged_tests_by_root(
125
249
  all_test_paths: list[Path], repository_root: Path
126
250
  ) -> dict[Path, list[Path]]:
127
- """Group *all_test_paths* by their owning pytest-config root.
251
+ """Group *all_test_paths* by the pytest session root that runs them.
128
252
 
129
253
  Args:
130
254
  all_test_paths: The staged test files to partition.
131
255
  repository_root: The repository root that bounds each upward walk.
132
256
 
133
257
  Returns:
134
- A mapping from owning test root to the staged test files under it.
258
+ A mapping from group root to the staged test files running under it.
259
+ Every input path lands in exactly one group.
135
260
  """
136
261
  all_tests_by_root: dict[Path, list[Path]] = {}
137
262
  for each_test_path in all_test_paths:
138
- owning_root = _resolve_owning_test_root(each_test_path, repository_root)
139
- all_tests_by_root.setdefault(owning_root, []).append(each_test_path)
263
+ group_root = _group_root_for_test_file(each_test_path, repository_root)
264
+ all_tests_by_root.setdefault(group_root, []).append(each_test_path)
140
265
  return all_tests_by_root
141
266
 
142
267
 
@@ -256,23 +381,36 @@ def _batched_pytest_arguments(
256
381
  return all_batches
257
382
 
258
383
 
259
- def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
384
+ def _pytest_batch_exit_code(
385
+ all_batch_command: list[str],
386
+ group_root: Path,
387
+ environment: dict[str, str],
388
+ junit_xml_path: Path | None = None,
389
+ ) -> int:
260
390
  """Run one pytest invocation and normalize its exit code.
261
391
 
262
392
  Args:
263
393
  all_batch_command: The full argv for this pytest invocation.
264
394
  group_root: The owning test root used as the working directory.
395
+ environment: The environment the pytest subprocess runs under, which
396
+ decides where its imports resolve from.
397
+ junit_xml_path: When given, the batch also writes a JUnit XML report
398
+ here (via ``--junitxml``), for a caller that needs the per-test
399
+ pass/fail identities alongside the exit code.
265
400
 
266
401
  Returns:
267
402
  0 when the batch passes or collects no tests; pytest's exit code
268
403
  otherwise.
269
404
  """
405
+ full_command = list(all_batch_command)
406
+ if junit_xml_path is not None:
407
+ full_command.append(f"{JUNIT_XML_FLAG_PREFIX}{junit_xml_path}")
270
408
  pytest_process = subprocess.run(
271
- all_batch_command,
409
+ full_command,
272
410
  cwd=str(group_root),
273
411
  timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
274
412
  check=False,
275
- env=_staged_pytest_environment(),
413
+ env=environment,
276
414
  )
277
415
  if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
278
416
  return 0
@@ -288,19 +426,49 @@ def _pytest_fixed_command(repository_root: Path) -> list[str]:
288
426
 
289
427
 
290
428
  def _first_failing_batch_exit_code(
291
- all_fixed_command: list[str], all_batches: list[list[str]], group_root: Path
429
+ all_fixed_command: list[str],
430
+ all_batches: list[list[str]],
431
+ group_root: Path,
432
+ environment: dict[str, str],
433
+ junit_xml_dir: Path | None = None,
292
434
  ) -> int:
293
- """Run each batch and return the first non-zero pytest exit code, or zero."""
435
+ """Run each batch and return the first non-zero pytest exit code, or zero.
436
+
437
+ Args:
438
+ all_fixed_command: The interpreter-and-pytest prefix shared by every batch.
439
+ all_batches: The command-line-length-safe argument batches to run in order.
440
+ group_root: The owning test root used as the working directory.
441
+ environment: The environment every pytest subprocess runs under.
442
+ junit_xml_dir: When given, each batch also writes its own JUnit XML report
443
+ here (one file per batch index), for a caller that needs per-test
444
+ pass/fail identities alongside the exit code.
445
+
446
+ Returns:
447
+ 0 when every batch passes or collects no tests; the first non-zero batch
448
+ exit code otherwise.
449
+ """
294
450
  first_failing_exit_code = 0
295
- for each_batch in all_batches:
296
- batch_exit_code = _pytest_batch_exit_code([*all_fixed_command, *each_batch], group_root)
451
+ for batch_index, each_batch in enumerate(all_batches):
452
+ junit_xml_path = (
453
+ junit_xml_dir / f"batch_{batch_index}.xml" if junit_xml_dir is not None else None
454
+ )
455
+ batch_exit_code = _pytest_batch_exit_code(
456
+ [*all_fixed_command, *each_batch],
457
+ group_root,
458
+ environment,
459
+ junit_xml_path=junit_xml_path,
460
+ )
297
461
  if batch_exit_code != 0 and first_failing_exit_code == 0:
298
462
  first_failing_exit_code = batch_exit_code
299
463
  return first_failing_exit_code
300
464
 
301
465
 
302
466
  def _run_pytest_for_group(
303
- group_root: Path, all_group_test_paths: list[Path], repository_root: Path
467
+ group_root: Path,
468
+ all_group_test_paths: list[Path],
469
+ repository_root: Path,
470
+ junit_xml_dir: Path | None = None,
471
+ environment: dict[str, str] | None = None,
304
472
  ) -> int:
305
473
  """Run pytest over one group's test files, split into budget-safe batches.
306
474
 
@@ -308,6 +476,13 @@ def _run_pytest_for_group(
308
476
  group_root: The owning test root used as the pytest working directory.
309
477
  all_group_test_paths: The staged test files that share *group_root*.
310
478
  repository_root: The repository root used to resolve a project venv.
479
+ junit_xml_dir: When given, a JUnit XML report is written per batch under
480
+ this directory, for a caller that needs per-test pass/fail identities
481
+ (the regression gate) alongside the exit code.
482
+ environment: When given, the environment every pytest subprocess runs
483
+ under; the regression gate passes a baseline environment here so the
484
+ HEAD run resolves its imports inside the baseline worktree. Defaults
485
+ to the staged environment.
311
486
 
312
487
  Returns:
313
488
  0 when every batch passes or collects nothing; the first failing batch's
@@ -321,49 +496,10 @@ def _run_pytest_for_group(
321
496
  [_relative_pytest_argument(each_path, group_root) for each_path in all_group_test_paths],
322
497
  MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
323
498
  )
324
- return _first_failing_batch_exit_code(all_fixed_command, all_batches, group_root)
325
-
326
-
327
- def _run_grouped_staged_tests(
328
- all_tests_by_root: dict[Path, list[Path]], repository_root: Path
329
- ) -> int:
330
- """Run each staged test group and return the first failing exit code, or zero."""
331
- first_failing_exit_code = 0
332
- for each_group_root in sorted(all_tests_by_root):
333
- group_exit_code = _run_pytest_for_group(
334
- each_group_root, all_tests_by_root[each_group_root], repository_root
335
- )
336
- if group_exit_code == 0:
337
- continue
338
- sys.stderr.write(
339
- STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n"
340
- )
341
- if first_failing_exit_code == 0:
342
- first_failing_exit_code = group_exit_code
343
- return first_failing_exit_code
344
-
345
-
346
- def run_staged_test_files(repository_root: Path) -> int:
347
- """Run pytest over the staged test files and return the gate exit code.
348
-
349
- ``conftest.py`` files are excluded from collection targets. Pytest still
350
- loads them automatically when a nearby staged test runs under the same
351
- owning root.
352
-
353
- Args:
354
- repository_root: The repository root the staged test files belong to.
355
-
356
- Returns:
357
- 0 when no collectable test file is staged, when every group collects no
358
- tests, or when every group passes. The first failing group's exit code
359
- otherwise.
360
- """
361
- all_test_paths = _staged_test_file_paths(repository_root)
362
- all_pytest_targets = _pytest_target_paths(all_test_paths)
363
- if not all_pytest_targets:
364
- return 0
365
- all_tests_by_root = _group_staged_tests_by_root(all_pytest_targets, repository_root)
366
- first_failing_exit_code = _run_grouped_staged_tests(all_tests_by_root, repository_root)
367
- if first_failing_exit_code != 0:
368
- sys.stderr.write(STAGED_TEST_FAILURE_HEADER + "\n")
369
- return first_failing_exit_code
499
+ return _first_failing_batch_exit_code(
500
+ all_fixed_command,
501
+ all_batches,
502
+ group_root,
503
+ _staged_pytest_environment() if environment is None else environment,
504
+ junit_xml_dir=junit_xml_dir,
505
+ )
@@ -0,0 +1 @@
1
+ """Package marker so ``code_rules_gate_parts.tests`` submodules import by dotted path."""
@@ -0,0 +1,76 @@
1
+ """Shared real-git-repository builders for the code_rules_gate_parts test suite.
2
+
3
+ Every helper drives a real ``git`` subprocess against a throwaway repository
4
+ under ``tmp_path`` — no mocked git state — so a test proves the module works
5
+ against the same git plumbing the live commit hook runs against.
6
+ """
7
+
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from code_rules_gate_parts import git_file_sets
12
+
13
+
14
+ def run_git(repository_root: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]:
15
+ """Run one git subcommand in *repository_root* and raise on failure."""
16
+ return subprocess.run(
17
+ ["git", *arguments],
18
+ cwd=str(repository_root),
19
+ check=True,
20
+ capture_output=True,
21
+ env=git_file_sets.repository_environment(),
22
+ )
23
+
24
+
25
+ def init_repository(repository_root: Path) -> None:
26
+ """Initialize *repository_root* as a git repo with one seed commit."""
27
+ run_git(repository_root, "init", "--initial-branch=main")
28
+ run_git(repository_root, "config", "user.email", "test@example.com")
29
+ run_git(repository_root, "config", "user.name", "Test")
30
+ run_git(repository_root, "config", "commit.gpgsign", "false")
31
+ (repository_root / "seed.txt").write_text("seed\n", encoding="utf-8")
32
+ run_git(repository_root, "add", "-A")
33
+ run_git(repository_root, "commit", "--no-verify", "-m", "seed")
34
+
35
+
36
+ def write_and_stage(repository_root: Path, relative_path: str, file_text: str) -> Path:
37
+ """Write *file_text* to *relative_path* under *repository_root* and stage it."""
38
+ file_path = repository_root / relative_path
39
+ file_path.parent.mkdir(parents=True, exist_ok=True)
40
+ file_path.write_text(file_text, encoding="utf-8")
41
+ run_git(repository_root, "add", "--", relative_path)
42
+ return file_path
43
+
44
+
45
+ def write_commit_and_stage_change(
46
+ repository_root: Path, relative_path: str, committed_text: str, staged_text: str
47
+ ) -> Path:
48
+ """Commit *committed_text* at *relative_path*, then overwrite and stage *staged_text*.
49
+
50
+ Builds the shape a regression check compares: real history at HEAD (what
51
+ the baseline run sees), then a staged edit on top of it (what the staged
52
+ run sees).
53
+ """
54
+ file_path = write_and_stage(repository_root, relative_path, committed_text)
55
+ run_git(repository_root, "commit", "--no-verify", "-m", f"seed {relative_path}")
56
+ file_path.write_text(staged_text, encoding="utf-8")
57
+ run_git(repository_root, "add", "--", relative_path)
58
+ return file_path
59
+
60
+
61
+ def repository_root_without_pytest_config(tmp_path: Path) -> Path:
62
+ """Return a fresh repository-root directory holding no pytest configuration."""
63
+ repository_root = tmp_path / "repo"
64
+ repository_root.mkdir()
65
+ return repository_root
66
+
67
+
68
+ def repository_with_root_pytest_config(tmp_path: Path) -> Path:
69
+ """Return a fresh repo under *tmp_path* with a root-level ``pytest.ini`` committed."""
70
+ repository_root = tmp_path / "repo"
71
+ repository_root.mkdir()
72
+ init_repository(repository_root)
73
+ (repository_root / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
74
+ run_git(repository_root, "add", "-A")
75
+ run_git(repository_root, "commit", "--no-verify", "-m", "add pytest.ini")
76
+ return repository_root