claude-dev-env 1.93.0 → 1.94.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 (220) hide show
  1. package/_shared/pr-loop/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/audit-contract.md +1 -1
  3. package/_shared/pr-loop/gh-payloads.md +3 -3
  4. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  5. package/_shared/pr-loop/scripts/README.md +2 -2
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
  7. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  9. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
  10. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  11. package/_shared/pr-loop/state-schema.md +3 -22
  12. package/bin/CLAUDE.md +1 -1
  13. package/bin/install.mjs +2 -7
  14. package/bin/install.test.mjs +10 -4
  15. package/hooks/blocking/CLAUDE.md +2 -0
  16. package/hooks/blocking/pii_commit_command.py +398 -0
  17. package/hooks/blocking/pii_payload_scan.py +133 -0
  18. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  19. package/hooks/blocking/pii_scanner.py +26 -1
  20. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  21. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  22. package/hooks/blocking/test_pii_scanner.py +27 -2
  23. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  24. package/hooks/hooks_constants/CLAUDE.md +2 -2
  25. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  26. package/hooks/hooks_constants/local_identity.py +65 -15
  27. package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
  28. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  29. package/hooks/hooks_constants/test_local_identity.py +68 -0
  30. package/package.json +1 -1
  31. package/scripts/CLAUDE.md +10 -1
  32. package/scripts/Get-SessionAccount.ps1 +210 -0
  33. package/scripts/tests/CLAUDE.md +15 -1
  34. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  35. package/skills/CLAUDE.md +3 -25
  36. package/skills/anthropic-plan/SKILL.md +1 -13
  37. package/skills/autoconverge/CLAUDE.md +5 -2
  38. package/skills/autoconverge/SKILL.md +112 -387
  39. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  40. package/skills/autoconverge/reference/closing-report.md +9 -2
  41. package/skills/autoconverge/reference/convergence.md +26 -22
  42. package/skills/autoconverge/reference/headless-safety.md +44 -0
  43. package/skills/autoconverge/reference/multi-pr.md +88 -0
  44. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  45. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  46. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  47. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  49. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  50. package/skills/autoconverge/workflow/converge.mjs +284 -94
  51. package/skills/bugteam/CLAUDE.md +1 -1
  52. package/skills/bugteam/CONSTRAINTS.md +3 -3
  53. package/skills/bugteam/PROMPTS.md +8 -19
  54. package/skills/bugteam/SKILL.md +13 -14
  55. package/skills/bugteam/reference/CLAUDE.md +1 -1
  56. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  57. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  58. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  59. package/skills/bugteam/reference/team-setup.md +11 -11
  60. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  61. package/skills/bugteam/scripts/README.md +20 -8
  62. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  63. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  64. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  65. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  66. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  67. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  68. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  69. package/skills/copilot-finding-triage/SKILL.md +80 -28
  70. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  71. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  72. package/skills/everything-search/SKILL.md +1 -2
  73. package/skills/grokify/SKILL.md +58 -0
  74. package/skills/grokify/templates/handoff-template.md +92 -0
  75. package/skills/orchestrator/SKILL.md +12 -8
  76. package/skills/orchestrator-refresh/SKILL.md +3 -3
  77. package/skills/pr-converge/SKILL.md +132 -233
  78. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  79. package/skills/pr-converge/reference/examples.md +37 -25
  80. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  81. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  82. package/skills/pr-converge/reference/per-tick.md +64 -41
  83. package/skills/pr-converge/reference/state-schema.md +27 -2
  84. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  85. package/skills/pr-converge/scripts/README.md +4 -3
  86. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  87. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  88. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  89. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  90. package/skills/pr-converge/scripts/conftest.py +8 -0
  91. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  92. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  93. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  94. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  95. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  96. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  97. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  98. package/skills/privacy-hygiene/SKILL.md +8 -7
  99. package/skills/reviewer-gates/SKILL.md +26 -8
  100. package/skills/session-log/SKILL.md +11 -12
  101. package/skills/session-tidy/SKILL.md +40 -30
  102. package/skills/skill-builder/CLAUDE.md +13 -10
  103. package/skills/skill-builder/SKILL.md +31 -31
  104. package/skills/skill-builder/references/CLAUDE.md +10 -6
  105. package/skills/skill-builder/references/delegation-map.md +13 -4
  106. package/skills/skill-builder/references/description-field.md +113 -0
  107. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  108. package/skills/skill-builder/references/skill-modularity.md +113 -0
  109. package/skills/skill-builder/references/skill-types.md +4 -0
  110. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  111. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  112. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  113. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  114. package/skills/skill-builder/workflows/new-skill.md +34 -10
  115. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  116. package/skills/usage-pause/SKILL.md +10 -14
  117. package/skills/bdd-protocol/CLAUDE.md +0 -26
  118. package/skills/bdd-protocol/SKILL.md +0 -30
  119. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  120. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  121. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  122. package/skills/bg-agent/CLAUDE.md +0 -17
  123. package/skills/bg-agent/SKILL.md +0 -69
  124. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  125. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  127. package/skills/caveman/CLAUDE.md +0 -15
  128. package/skills/caveman/SKILL.md +0 -38
  129. package/skills/code/CLAUDE.md +0 -17
  130. package/skills/code/SKILL.md +0 -178
  131. package/skills/deep-research/CLAUDE.md +0 -17
  132. package/skills/deep-research/SKILL.md +0 -80
  133. package/skills/findbugs/CLAUDE.md +0 -20
  134. package/skills/findbugs/SKILL.md +0 -241
  135. package/skills/fixbugs/CLAUDE.md +0 -19
  136. package/skills/fixbugs/SKILL.md +0 -142
  137. package/skills/gh-paginate/CLAUDE.md +0 -18
  138. package/skills/gh-paginate/SKILL.md +0 -84
  139. package/skills/gotcha/CLAUDE.md +0 -33
  140. package/skills/gotcha/SKILL.md +0 -73
  141. package/skills/implement/CLAUDE.md +0 -27
  142. package/skills/implement/SKILL.md +0 -66
  143. package/skills/implement/scripts/CLAUDE.md +0 -22
  144. package/skills/implement/scripts/append_note.py +0 -133
  145. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  146. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  147. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  148. package/skills/implement/scripts/test_append_note.py +0 -191
  149. package/skills/log-audit/CLAUDE.md +0 -20
  150. package/skills/log-audit/SKILL.md +0 -66
  151. package/skills/log-audit/reference/CLAUDE.md +0 -9
  152. package/skills/log-audit/reference/charter.md +0 -52
  153. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  154. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  155. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  156. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  157. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  158. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  159. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  160. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  161. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  162. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  163. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  164. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  165. package/skills/logifix/CLAUDE.md +0 -36
  166. package/skills/logifix/SKILL.md +0 -69
  167. package/skills/logifix/scripts/CLAUDE.md +0 -16
  168. package/skills/logifix/scripts/logifix.ps1 +0 -205
  169. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  170. package/skills/monitor-open-prs/SKILL.md +0 -88
  171. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  172. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  173. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  174. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  175. package/skills/post-audit-findings/SKILL.md +0 -80
  176. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  177. package/skills/pr-consistency-audit/SKILL.md +0 -116
  178. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  179. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  180. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  181. package/skills/pr-fix-protocol/SKILL.md +0 -65
  182. package/skills/pr-review-responder/CLAUDE.md +0 -35
  183. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  184. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  185. package/skills/pr-review-responder/README.md +0 -168
  186. package/skills/pr-review-responder/SKILL.md +0 -203
  187. package/skills/pr-review-responder/TESTING.md +0 -350
  188. package/skills/pr-scope-resolve/SKILL.md +0 -45
  189. package/skills/pre-compact/CLAUDE.md +0 -24
  190. package/skills/pre-compact/SKILL.md +0 -134
  191. package/skills/qbug/CLAUDE.md +0 -40
  192. package/skills/qbug/SKILL.md +0 -387
  193. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  194. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  195. package/skills/refine/CLAUDE.md +0 -44
  196. package/skills/refine/SKILL.md +0 -262
  197. package/skills/refine/templates/CLAUDE.md +0 -17
  198. package/skills/refine/templates/implementation-notes-template.html +0 -56
  199. package/skills/refine/templates/plan-template.md +0 -60
  200. package/skills/research-mode/CLAUDE.md +0 -35
  201. package/skills/research-mode/SKILL.md +0 -53
  202. package/skills/structure-prompt/CLAUDE.md +0 -42
  203. package/skills/structure-prompt/SKILL.md +0 -44
  204. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  205. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  206. package/skills/structure-prompt/reference/block-classification.md +0 -27
  207. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  208. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  209. package/skills/structure-prompt/reference/cleanup.md +0 -33
  210. package/skills/structure-prompt/reference/constraints.md +0 -33
  211. package/skills/structure-prompt/reference/directives.md +0 -37
  212. package/skills/structure-prompt/reference/examples.md +0 -72
  213. package/skills/structure-prompt/reference/instantiation.md +0 -51
  214. package/skills/structure-prompt/reference/output-contract.md +0 -72
  215. package/skills/structure-prompt/reference/per-category.md +0 -23
  216. package/skills/structure-prompt/reference/persona.md +0 -38
  217. package/skills/structure-prompt/reference/research.md +0 -33
  218. package/skills/structure-prompt/reference/structure.md +0 -28
  219. package/skills/verified-build/CLAUDE.md +0 -33
  220. package/skills/verified-build/SKILL.md +0 -33
@@ -8,6 +8,7 @@ Runtime documents and scripts shared by every PR-loop skill. Changes here affect
8
8
  |---|---|
9
9
  | `audit-contract.md` | Canonical finding schema (Shape A / Shape B) and loop contract; defines the JSON shapes every audit skill must emit |
10
10
  | `audit-reply-template.md` | Canonical reply skeleton Claude posts to each unresolved review thread; single source of truth for reply structure |
11
+ | `post-audit-thread-contract.md` | Single source of truth for the `post_audit_thread.py` invocation string, exit-code table, and per-caller policy (bugteam exit 2 = hard blocker; autoconverge clean-audit failed post = recorded bypass) |
11
12
  | `fix-protocol.md` | Ordered sequence a fix lens follows: read, capture SHA, TDD, apply, validate, self-audit, commit, push, reply + resolve |
12
13
  | `gh-payloads.md` | How to build GitHub review and reply payloads via MCP tools; describes the one-review-per-loop pattern |
13
14
  | `state-schema.md` | Fields each PR-loop workflow tracks across iterations; documents common fields and per-skill extensions |
@@ -120,7 +120,7 @@ Sequence:
120
120
  2. Apply edits.
121
121
  3. Run `py_compile` (or language-equivalent) on each modified file.
122
122
  4. Compute `fix_diff` against pre-fix contents for the modified set.
123
- 5. Run `bugteam_code_rules_gate.py` with explicit paths for every modified file.
123
+ 5. Run `_shared/pr-loop/scripts/code_rules_gate.py` with explicit paths for every modified file.
124
124
  6. Spawn a scoped audit of `fix_diff` with full A–P rigor, Shape A/B contract, adversarial pass, AND Haiku secondary in parallel (paranoid mode on post-fix).
125
125
  7. Read the previous loop's outcome XML (`<worktree_path>/.bugteam-pr<N>-loop<L-1>.outcomes.xml`) and obtain its total finding count. If this is the first loop (L <= 1) or the file does not exist, skip this comparison. Compute the post-fix total: previous total minus bugs fixed in this round plus new violations found in the post-fix audit (step 6). If the post-fix total exceeds the previous total, flag all new findings as same-loop fix-targets and revise. An increase in total findings across loop transitions is a regression.
126
126
  8. Any new findings become same-loop fix-targets. Internal iteration count increments by one.
@@ -55,9 +55,9 @@ user-facing output (chat reply to the user) rather than in the PR review body.
55
55
 
56
56
  ## Review POST failure
57
57
 
58
- There is no fallback path. `post_audit_thread.py` exits `2` on retry
59
- exhaustion (four non-2xx responses across the built-in `1s / 4s / 16s` backoff)
60
- and the orchestrator halts with `error: post_audit_thread retry exhausted`.
58
+ There is no fallback payload to build. On retry exhaustion
59
+ `post_audit_thread.py` exits `2` and the orchestrator halts see
60
+ [`./post-audit-thread-contract.md`](./post-audit-thread-contract.md) § Exit codes.
61
61
  A hard blocker on the audit-posting path is a halt condition, not a degraded
62
62
  flat-issue-comment fall-through.
63
63
 
@@ -0,0 +1,51 @@
1
+ # post_audit_thread.py invocation contract
2
+
3
+ Single source of truth for how every PR-loop caller runs
4
+ [`scripts/post_audit_thread.py`](scripts/post_audit_thread.py) to post one
5
+ audit review per pass. The script posts an `APPROVE` review on `CLEAN` (an
6
+ empty findings list, zero inline comments) and a `REQUEST_CHANGES` review on
7
+ `DIRTY` (one inline anchored comment per finding). It reads its body skeleton
8
+ from [`audit-reply-template.md`](audit-reply-template.md) at runtime.
9
+
10
+ ## Invocation
11
+
12
+ ```
13
+ python "<pr-loop-scripts>/post_audit_thread.py" \
14
+ --skill <caller> \
15
+ --owner <owner> \
16
+ --repo <repo> \
17
+ --pr-number <N> \
18
+ --commit <head_sha> \
19
+ --state <CLEAN|DIRTY> \
20
+ --findings-json <path>
21
+ ```
22
+
23
+ Capture `<head_sha>` with `git rev-parse HEAD` in the caller's cwd right before
24
+ the call, so the review attaches to the commit the audit scoped. `--findings-json`
25
+ points to a JSON file whose root is a list; on `CLEAN` the list is empty (`[]`).
26
+
27
+ ## Exit codes
28
+
29
+ The script retries on its own with 1s / 4s / 16s backoff across four tries (one
30
+ plus three retries). It then exits:
31
+
32
+ | Exit | Meaning |
33
+ |---|---|
34
+ | `0` | Review posted. The new review's `html_url` is on stdout. |
35
+ | `1` | User input error: bad arguments, malformed findings JSON, or a missing template. |
36
+ | `2` | Retry exhaustion: every try failed. |
37
+
38
+ ## Per-caller policy
39
+
40
+ The same exit codes carry different weight per caller:
41
+
42
+ - **bugteam** — an exit `2` is a hard blocker. The lead exits with
43
+ `error: post_audit_thread retry exhausted`, and does not retry or fall back to
44
+ a flat issue comment.
45
+ - **autoconverge clean-audit** — any post that does not land (a denied post, an
46
+ error, or an agent that never ran) is a recorded bypass, not a blocker. Every
47
+ review lens already cleared the HEAD, so the CLEAN post is a record-keeping
48
+ artifact. The run records a bypass note, sets the convergence check's
49
+ `--bugteam-post-blocked` flag, and proceeds to the terminal Bugbot gate. Full
50
+ policy: [`../../skills/autoconverge/reference/stop-conditions.md`](../../skills/autoconverge/reference/stop-conditions.md)
51
+ § "Clean-audit post bypassed".
@@ -9,11 +9,11 @@ Runnable helpers used by **bugteam**, **qbug**, **pr-converge**, and related ski
9
9
  | `preflight.py` | Local checks before a PR-loop run (pytest discovery, optional pre-commit, hooksPath sanity). |
10
10
  | `reviewer_availability.py` | Unified availability check for Copilot and Bugbot; reuses `copilot_quota.py` and `reviews_disabled.py` and exits 0 when the named `--reviewer` may be spawned. |
11
11
  | `code_rules_gate.py` | CODE_RULES gate over PR-scoped diffs (`--base`, staged-only, path filters). |
12
- | `fix_hookspath.py` | Repair `core.hooksPath` when it does not point at the packaged git-hooks tree. |
12
+ | `fix_hookspath.py` | Repair `core.hooksPath` when it does not point at the packaged git-hooks tree. Single home for unexpected non-empty git stderr (hard-fail `RuntimeError`); skill wrappers may call it later. |
13
13
  | `grant_project_claude_permissions.py` / `revoke_project_claude_permissions.py` | Claude Code permission JSON helpers used during publish-style flows. |
14
14
  | `_claude_permissions_common.py` | Shared implementation for the permission scripts. |
15
15
 
16
- Configuration lives under `config/` next to these scripts (for example `preflight_constants.py`, `code_rules_gate_constants.py`).
16
+ Configuration lives under `pr_loop_shared_constants/` next to these scripts (for example `preflight_constants.py`, `code_rules_gate_constants.py`, `fix_hookspath_constants.py`).
17
17
 
18
18
  ## Gate semantics
19
19
 
@@ -28,6 +28,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
28
28
  BANNED_NOUN_VIOLATION_PATTERN,
29
29
  CODE_RULES_GATE_PYTHON_ENV_VAR,
30
30
  CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
31
+ COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
31
32
  DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
32
33
  DUPLICATE_BODY_SPAN_GROUP_INDEX,
33
34
  DUPLICATE_BODY_VIOLATION_PATTERN,
@@ -41,6 +42,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
41
42
  ISOLATION_DEFINITION_LINE_GROUP_INDEX,
42
43
  ISOLATION_SPAN_GROUP_INDEX,
43
44
  ISOLATION_VIOLATION_PATTERN,
45
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
44
46
  MAX_VIOLATIONS_PER_CHECK,
45
47
  MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
46
48
  MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
@@ -1710,11 +1712,114 @@ def _staged_pytest_environment() -> dict[str, str]:
1710
1712
  return environment
1711
1713
 
1712
1714
 
1715
+ def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
1716
+ """Express *test_path* as a pytest argument relative to *group_root*.
1717
+
1718
+ *group_root* is already the pytest working directory, so the relative form
1719
+ names the same file in far fewer characters.
1720
+
1721
+ Example::
1722
+
1723
+ group_root = C:/repo/package
1724
+ ok: C:/repo/package/tests/test_a.py -> "tests/test_a.py"
1725
+ flag: C:/elsewhere/test_b.py -> no relative form; passed absolute
1726
+
1727
+ Args:
1728
+ test_path: The staged test file to express as a pytest argument.
1729
+ group_root: The pytest working directory for the group.
1730
+
1731
+ Returns:
1732
+ The path relative to *group_root* when *test_path* sits under it,
1733
+ otherwise the absolute path unchanged.
1734
+ """
1735
+ try:
1736
+ return str(test_path.relative_to(group_root))
1737
+ except ValueError:
1738
+ return str(test_path)
1739
+
1740
+
1741
+ def _batched_pytest_arguments(
1742
+ all_pytest_arguments: list[str], character_budget: int
1743
+ ) -> list[list[str]]:
1744
+ """Split pytest path arguments into command-line-length-safe batches.
1745
+
1746
+ Windows rejects a command line past 32767 characters with WinError 206, so a
1747
+ large staged set passed in one invocation crashes the gate and denies the
1748
+ commit. Each batch takes arguments until the next one would push its joined
1749
+ length past *character_budget*.
1750
+
1751
+ Example::
1752
+
1753
+ character_budget = 10
1754
+ arguments = ["aaaa", "bbbb", "cccc"]
1755
+ ok: [["aaaa", "bbbb"], ["cccc"]] - every batch within budget
1756
+ flag: [["aaaa", "bbbb", "cccc"]] - 14 characters, over budget
1757
+
1758
+ Args:
1759
+ all_pytest_arguments: The path arguments to distribute, in order.
1760
+ character_budget: The joined length each batch must stay within. An
1761
+ argument wider than the budget on its own still lands in a batch
1762
+ by itself, so no argument is ever dropped.
1763
+
1764
+ Returns:
1765
+ One argument list per pytest invocation, preserving input order.
1766
+ """
1767
+ all_batches: list[list[str]] = []
1768
+ current_batch: list[str] = []
1769
+ current_length = 0
1770
+ for each_argument in all_pytest_arguments:
1771
+ argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
1772
+ if current_batch and current_length + argument_length > character_budget:
1773
+ all_batches.append(current_batch)
1774
+ current_batch = []
1775
+ current_length = 0
1776
+ current_batch.append(each_argument)
1777
+ current_length += argument_length
1778
+ if current_batch:
1779
+ all_batches.append(current_batch)
1780
+ return all_batches
1781
+
1782
+
1783
+ def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
1784
+ """Run one pytest invocation and normalize its exit code.
1785
+
1786
+ Args:
1787
+ all_batch_command: The full argv for this pytest invocation.
1788
+ group_root: The owning test root used as the working directory.
1789
+
1790
+ Returns:
1791
+ 0 when the batch passes or collects no tests; pytest's non-zero exit
1792
+ code otherwise.
1793
+ """
1794
+ pytest_process = subprocess.run(
1795
+ all_batch_command,
1796
+ cwd=str(group_root),
1797
+ timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
1798
+ check=False,
1799
+ env=_staged_pytest_environment(),
1800
+ )
1801
+ if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
1802
+ return 0
1803
+ return pytest_process.returncode
1804
+
1805
+
1713
1806
  def _run_pytest_for_group(
1714
1807
  group_root: Path, all_group_test_paths: list[Path], repository_root: Path
1715
1808
  ) -> int:
1716
1809
  """Run pytest over one group's test files with the working directory at its root.
1717
1810
 
1811
+ Paths are passed relative to *group_root* (the subprocess working directory)
1812
+ and split into batches whose joined command line stays within
1813
+ ``MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS``, so a large staged set never
1814
+ trips the Windows 32767-character limit. A staged set that fits the budget runs
1815
+ as one invocation, exactly as an unbatched run did.
1816
+
1817
+ Example::
1818
+
1819
+ ok: 3 staged tests -> 1 pytest invocation, argv well under the budget
1820
+ flag: 800 staged tests -> 1 pytest invocation, argv over the Windows limit
1821
+ (WinError 206); batching splits it across several
1822
+
1718
1823
  Args:
1719
1824
  group_root: The owning test root used as the pytest working directory.
1720
1825
  all_group_test_paths: The staged test files that share *group_root*.
@@ -1722,23 +1827,32 @@ def _run_pytest_for_group(
1722
1827
  interpreter when ``CODE_RULES_GATE_PYTHON`` is not set.
1723
1828
 
1724
1829
  Returns:
1725
- 0 when the group's tests pass or collect nothing; pytest's non-zero
1726
- exit code otherwise.
1830
+ 0 when every batch passes or collects nothing; the first failing batch's
1831
+ non-zero pytest exit code otherwise.
1727
1832
  """
1728
- pytest_process = subprocess.run(
1833
+ all_fixed_command = [
1834
+ _resolve_gate_python_executable(repository_root),
1835
+ *ALL_PYTEST_MODULE_INVOCATION,
1836
+ ]
1837
+ fixed_command_length = sum(
1838
+ len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
1839
+ for each_part in all_fixed_command
1840
+ )
1841
+ all_batches = _batched_pytest_arguments(
1729
1842
  [
1730
- _resolve_gate_python_executable(repository_root),
1731
- *ALL_PYTEST_MODULE_INVOCATION,
1732
- *[str(each_path) for each_path in all_group_test_paths],
1843
+ _relative_pytest_argument(each_path, group_root)
1844
+ for each_path in all_group_test_paths
1733
1845
  ],
1734
- cwd=str(group_root),
1735
- timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
1736
- check=False,
1737
- env=_staged_pytest_environment(),
1846
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
1738
1847
  )
1739
- if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
1740
- return 0
1741
- return pytest_process.returncode
1848
+ first_failing_exit_code = 0
1849
+ for each_batch in all_batches:
1850
+ batch_exit_code = _pytest_batch_exit_code(
1851
+ [*all_fixed_command, *each_batch], group_root
1852
+ )
1853
+ if batch_exit_code != 0 and first_failing_exit_code == 0:
1854
+ first_failing_exit_code = batch_exit_code
1855
+ return first_failing_exit_code
1742
1856
 
1743
1857
 
1744
1858
  def run_staged_test_files(repository_root: Path) -> int:
@@ -53,6 +53,9 @@ def list_local_core_hooks_path_values(
53
53
  Returns:
54
54
  Non-empty stripped values from ``git config --local --get-all``, or
55
55
  an empty list when no values are configured.
56
+
57
+ Raises:
58
+ RuntimeError: When git exits non-zero with a non-empty stderr diagnostic.
56
59
  """
57
60
  git_command = [
58
61
  "git",
@@ -75,11 +78,9 @@ def list_local_core_hooks_path_values(
75
78
  if completed_process.returncode != 0:
76
79
  diagnostic_stderr = completed_process.stderr.strip()
77
80
  if diagnostic_stderr:
78
- print(
79
- "fix_hookspath: git read of local core.hooksPath on "
80
- f"{repository_root} exited {completed_process.returncode}: "
81
- f"{diagnostic_stderr}",
82
- file=sys.stderr,
81
+ raise RuntimeError(
82
+ f"git read of local core.hooksPath on {repository_root} "
83
+ f"exited {completed_process.returncode}: {diagnostic_stderr}"
83
84
  )
84
85
  return []
85
86
  return [
@@ -99,8 +100,11 @@ def read_global_core_hooks_path(
99
100
  forwarded to ``subprocess.run``.
100
101
 
101
102
  Returns:
102
- The stripped global value, or an empty string when unset or when git
103
- returns non-zero.
103
+ The stripped global value, or an empty string when the key is unset
104
+ (git exits non-zero with empty stderr).
105
+
106
+ Raises:
107
+ RuntimeError: When git exits non-zero with a non-empty stderr diagnostic.
104
108
  """
105
109
  git_command = list(ALL_GIT_GLOBAL_GET_CORE_HOOKS_PATH_COMMAND)
106
110
  completed_process = subprocess.run(
@@ -115,10 +119,9 @@ def read_global_core_hooks_path(
115
119
  if completed_process.returncode != 0:
116
120
  diagnostic_stderr = completed_process.stderr.strip()
117
121
  if diagnostic_stderr:
118
- print(
119
- "fix_hookspath: git read of global core.hooksPath exited "
120
- f"{completed_process.returncode}: {diagnostic_stderr}",
121
- file=sys.stderr,
122
+ raise RuntimeError(
123
+ f"git read of global core.hooksPath exited "
124
+ f"{completed_process.returncode}: {diagnostic_stderr}"
122
125
  )
123
126
  return ""
124
127
  return completed_process.stdout.strip()
@@ -280,7 +283,8 @@ def main(
280
283
  forwarded to every git invocation and to the preflight rerun.
281
284
 
282
285
  Returns:
283
- Zero on success. Non-zero on the first failing git command or on a
286
+ Zero on success. Non-zero on the first failing git command, on a
287
+ hard-fail git stderr diagnostic (caught ``RuntimeError``), or on a
284
288
  non-zero preflight rerun exit code.
285
289
  """
286
290
  arguments = parse_arguments(all_arguments)
@@ -302,6 +306,37 @@ def main(
302
306
  file=sys.stderr,
303
307
  )
304
308
  return 1
309
+ try:
310
+ return _repair_hooks_path_then_rerun_preflight(
311
+ repository_root=repository_root,
312
+ canonical_hooks_directory=canonical_hooks_directory,
313
+ all_environment_overrides=all_environment_overrides,
314
+ )
315
+ except RuntimeError as runtime_error:
316
+ print(f"fix_hookspath: {runtime_error}", file=sys.stderr)
317
+ return 1
318
+
319
+
320
+ def _repair_hooks_path_then_rerun_preflight(
321
+ repository_root: Path,
322
+ canonical_hooks_directory: Path,
323
+ all_environment_overrides: dict[str, str] | None,
324
+ ) -> int:
325
+ """Apply local/global hooksPath repairs, then re-run preflight.
326
+
327
+ Args:
328
+ repository_root: Repository root used for local git config and preflight.
329
+ canonical_hooks_directory: Absolute path to the packaged git-hooks tree.
330
+ all_environment_overrides: Optional environment variable mapping
331
+ forwarded to every git invocation and to the preflight rerun.
332
+
333
+ Returns:
334
+ Zero on success. Non-zero on the first failing git write or on a
335
+ non-zero preflight rerun exit code.
336
+
337
+ Raises:
338
+ RuntimeError: When a git read exits non-zero with non-empty stderr.
339
+ """
305
340
  local_hooks_path_values = list_local_core_hooks_path_values(
306
341
  repository_root,
307
342
  all_environment_overrides,
@@ -102,6 +102,10 @@ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS: tuple[str, ...] = ("bin", "python"
102
102
 
103
103
  STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
104
104
 
105
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS: int = 24000
106
+
107
+ COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH: int = 1
108
+
105
109
  STAGED_TEST_FAILURE_HEADER: str = (
106
110
  "code_rules_gate: staged test file(s) failed under pytest; commit blocked."
107
111
  )
@@ -483,16 +483,11 @@ def test_check_wrapper_plumb_through_exempts_test_files() -> None:
483
483
  " return _helper('a', clean_name='b')\n"
484
484
  )
485
485
  shared_issues = gate_module.check_wrapper_plumb_through(source, "pkg/test_thing.py")
486
- bugteam_gate = _load_bugteam_gate_module()
487
- bugteam_issues = bugteam_gate.check_wrapper_plumb_through(source, "pkg/test_thing.py")
488
486
  assert shared_issues == [], (
489
487
  "a test_* function in a test-file path that calls a module-level helper "
490
488
  "exposing an optional kwarg is not a wrapper; the shared gate must exempt "
491
489
  "test files and emit zero findings"
492
490
  )
493
- assert bugteam_issues == [], (
494
- "the bugteam gate copy must apply the identical test-file exemption"
495
- )
496
491
 
497
492
 
498
493
  def test_check_wrapper_plumb_through_still_flags_non_test_path_with_test_shape() -> None:
@@ -1245,8 +1240,6 @@ def test_check_wrapper_plumb_through_dedupes_nested_public_function_calls() -> N
1245
1240
  assert len(issues) == 1, f"expected 1 finding, got {len(issues)}: {issues!r}"
1246
1241
 
1247
1242
 
1248
-
1249
-
1250
1243
  def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
1251
1244
  """Regression: nested callees inside another call's arguments are not wrapper sites.
1252
1245
 
@@ -1766,68 +1759,6 @@ def test_main_blocks_banned_noun_on_added_lines_past_document_order(
1766
1759
  )
1767
1760
 
1768
1761
 
1769
- def _load_bugteam_gate_module() -> ModuleType:
1770
- bugteam_scripts_dir = (
1771
- Path(__file__).resolve().parents[4]
1772
- / "skills"
1773
- / "bugteam"
1774
- / "scripts"
1775
- )
1776
- if str(bugteam_scripts_dir) not in sys.path:
1777
- sys.path.insert(0, str(bugteam_scripts_dir))
1778
- module_path = bugteam_scripts_dir / "bugteam_code_rules_gate.py"
1779
- spec = importlib.util.spec_from_file_location("bugteam_code_rules_gate", module_path)
1780
- assert spec is not None
1781
- assert spec.loader is not None
1782
- module = importlib.util.module_from_spec(spec)
1783
- spec.loader.exec_module(module)
1784
- return module
1785
-
1786
-
1787
- def test_both_gates_classify_wrapper_plumb_through_identically() -> None:
1788
- """The bugteam and _shared gate copies of check_wrapper_plumb_through must
1789
- return identical findings. A class method calling a module-level delegate
1790
- is not a wrapper; both gates must exclude it rather than one emitting a
1791
- false positive the other does not."""
1792
- bugteam_gate = _load_bugteam_gate_module()
1793
- class_method_calling_delegate = (
1794
- "def fetch(target, *, retries=3):\n"
1795
- " return target\n"
1796
- "\n"
1797
- "class MyService:\n"
1798
- " def public_method(self, target):\n"
1799
- " return fetch(target)\n"
1800
- )
1801
- nested_call_inside_delegate_argument = (
1802
- "def delegate(value, *, retries=3):\n"
1803
- " return value\n"
1804
- "\n"
1805
- "def helper(value):\n"
1806
- " return value\n"
1807
- "\n"
1808
- "def public_caller(value):\n"
1809
- " return delegate(helper(value))\n"
1810
- )
1811
- name_call_dropping_kwarg = (
1812
- "def delegate(value, *, retries=3):\n"
1813
- " return value\n"
1814
- "\n"
1815
- "def public_wrapper(value):\n"
1816
- " return delegate(value)\n"
1817
- )
1818
- for each_source in (
1819
- class_method_calling_delegate,
1820
- nested_call_inside_delegate_argument,
1821
- name_call_dropping_kwarg,
1822
- ):
1823
- shared_issues = gate_module.check_wrapper_plumb_through(each_source, "module.py")
1824
- bugteam_issues = bugteam_gate.check_wrapper_plumb_through(each_source, "module.py")
1825
- assert shared_issues == bugteam_issues, (
1826
- "both gate copies of check_wrapper_plumb_through must classify "
1827
- f"identically; shared={shared_issues!r} bugteam={bugteam_issues!r}"
1828
- )
1829
-
1830
-
1831
1762
  def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
1832
1763
  """check_wrapper_plumb_through must stay under the enforcer's function-length
1833
1764
  blocking threshold so editing it (e.g. aligning the two gate copies) does
@@ -2722,3 +2653,149 @@ def test_staged_pytest_environment_prepends_configured_pythonpath(
2722
2653
  assert environment["PYTHONPATH"] == os.pathsep.join(
2723
2654
  ["/configured/site-packages", "/already/on/path"]
2724
2655
  )
2656
+
2657
+
2658
+ def _record_pytest_invocations(
2659
+ monkeypatch: pytest.MonkeyPatch, *, all_exit_codes: list[int]
2660
+ ) -> list[list[str]]:
2661
+ """Patch subprocess.run to record each pytest argv and return staged exit codes."""
2662
+ all_recorded_commands: list[list[str]] = []
2663
+ all_remaining_exit_codes = list(all_exit_codes)
2664
+
2665
+ def _fake_subprocess_run(
2666
+ all_command: list[str], **_keyword_arguments: object
2667
+ ) -> subprocess.CompletedProcess[bytes]:
2668
+ all_recorded_commands.append(list(all_command))
2669
+ exit_code = all_remaining_exit_codes.pop(0) if all_remaining_exit_codes else 0
2670
+ return subprocess.CompletedProcess(all_command, exit_code, b"", b"")
2671
+
2672
+ monkeypatch.setattr(gate_module.subprocess, "run", _fake_subprocess_run)
2673
+ return all_recorded_commands
2674
+
2675
+
2676
+ def _staged_test_paths_under(group_root: Path, path_count: int) -> list[Path]:
2677
+ return [
2678
+ group_root
2679
+ / "tests"
2680
+ / "subpackage_with_a_realistically_long_directory_name"
2681
+ / f"test_module_with_a_realistically_long_name_{each_index:04d}.py"
2682
+ for each_index in range(path_count)
2683
+ ]
2684
+
2685
+
2686
+ def _path_arguments_of(all_pytest_command: list[str]) -> list[str]:
2687
+ fixed_prefix_length = len(gate_module.ALL_PYTEST_MODULE_INVOCATION) + 1
2688
+ return all_pytest_command[fixed_prefix_length:]
2689
+
2690
+
2691
+ def test_run_pytest_for_group_batches_a_large_staged_set_under_the_budget(
2692
+ tmp_path: Path,
2693
+ monkeypatch: pytest.MonkeyPatch,
2694
+ ) -> None:
2695
+ """WinError 206: a staged set whose joined argv passes Windows' 32767-character
2696
+ command-line limit crashed the gate and denied the commit. The paths must be
2697
+ split across several pytest invocations, none of them over the budget.
2698
+ """
2699
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2700
+ all_test_paths = _staged_test_paths_under(tmp_path, 800)
2701
+
2702
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2703
+
2704
+ assert exit_code == 0
2705
+ assert len(all_recorded_commands) > 1, (
2706
+ "a staged set wider than the command-line budget must be split across "
2707
+ "several pytest invocations rather than passed in one oversized argv"
2708
+ )
2709
+ for each_command in all_recorded_commands:
2710
+ assert (
2711
+ len(" ".join(each_command))
2712
+ <= gate_module.MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS
2713
+ )
2714
+ all_batched_arguments = [
2715
+ each_argument
2716
+ for each_command in all_recorded_commands
2717
+ for each_argument in _path_arguments_of(each_command)
2718
+ ]
2719
+ assert len(all_batched_arguments) == len(all_test_paths)
2720
+
2721
+
2722
+ def test_run_pytest_for_group_passes_paths_relative_to_the_group_root(
2723
+ tmp_path: Path,
2724
+ monkeypatch: pytest.MonkeyPatch,
2725
+ ) -> None:
2726
+ """group_root is already the subprocess cwd, so each path argument is passed
2727
+ relative to it - the shortening that keeps a large staged set under the limit.
2728
+ """
2729
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2730
+ test_path = tmp_path / "tests" / "test_relative.py"
2731
+
2732
+ gate_module._run_pytest_for_group(tmp_path, [test_path], tmp_path)
2733
+
2734
+ assert _path_arguments_of(all_recorded_commands[0]) == [
2735
+ str(Path("tests") / "test_relative.py")
2736
+ ]
2737
+
2738
+
2739
+ def test_run_pytest_for_group_passes_an_outside_path_absolute(
2740
+ tmp_path: Path,
2741
+ monkeypatch: pytest.MonkeyPatch,
2742
+ ) -> None:
2743
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2744
+ group_root = tmp_path / "group"
2745
+ outside_path = tmp_path / "elsewhere" / "test_outside.py"
2746
+
2747
+ gate_module._run_pytest_for_group(group_root, [outside_path], tmp_path)
2748
+
2749
+ assert _path_arguments_of(all_recorded_commands[0]) == [str(outside_path)]
2750
+
2751
+
2752
+ def test_run_pytest_for_group_runs_one_invocation_for_a_small_staged_set(
2753
+ tmp_path: Path,
2754
+ monkeypatch: pytest.MonkeyPatch,
2755
+ ) -> None:
2756
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2757
+ all_test_paths = _staged_test_paths_under(tmp_path, 3)
2758
+
2759
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2760
+
2761
+ assert exit_code == 0
2762
+ assert len(all_recorded_commands) == 1, (
2763
+ "a staged set that fits the budget must behave exactly as before: "
2764
+ "one pytest invocation carrying every path"
2765
+ )
2766
+
2767
+
2768
+ def test_run_pytest_for_group_surfaces_the_first_failing_batch_exit_code(
2769
+ tmp_path: Path,
2770
+ monkeypatch: pytest.MonkeyPatch,
2771
+ ) -> None:
2772
+ """A failure in any batch must surface, and every batch must still run so the
2773
+ operator sees the whole staged-test picture rather than the first failure alone.
2774
+ """
2775
+ all_recorded_commands = _record_pytest_invocations(
2776
+ monkeypatch, all_exit_codes=[0, 1, 2]
2777
+ )
2778
+ all_test_paths = _staged_test_paths_under(tmp_path, 800)
2779
+
2780
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2781
+
2782
+ assert exit_code == 1
2783
+ assert len(all_recorded_commands) >= 3
2784
+
2785
+
2786
+ def test_run_pytest_for_group_treats_no_tests_collected_as_a_pass_per_batch(
2787
+ tmp_path: Path,
2788
+ monkeypatch: pytest.MonkeyPatch,
2789
+ ) -> None:
2790
+ _record_pytest_invocations(
2791
+ monkeypatch,
2792
+ all_exit_codes=[gate_module.PYTEST_NO_TESTS_COLLECTED_EXIT_CODE, 0],
2793
+ )
2794
+ all_test_paths = _staged_test_paths_under(tmp_path, 800)
2795
+
2796
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2797
+
2798
+ assert exit_code == 0, (
2799
+ "a batch that collects no tests is not a failure; only a real pytest "
2800
+ "failure exit code may block the commit"
2801
+ )