claude-dev-env 1.92.1 → 1.93.1

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 (215) hide show
  1. package/CLAUDE.md +6 -2
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +260 -14
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +292 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +365 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  79. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  82. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  84. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  85. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  86. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  87. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  88. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  89. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  90. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  91. package/hooks/blocking/test_pii_scanner.py +190 -0
  92. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  93. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  94. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  95. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  96. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  97. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  98. package/hooks/diagnostic/migrations/README.md +25 -26
  99. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  100. package/hooks/hooks.json +10 -120
  101. package/hooks/hooks_constants/CLAUDE.md +12 -3
  102. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  103. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  104. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  105. package/hooks/hooks_constants/local_identity.py +182 -0
  106. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  107. package/hooks/hooks_constants/pii_prevention_constants.py +295 -0
  108. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  109. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  110. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  111. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  112. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  113. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  114. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  115. package/hooks/hooks_constants/test_local_identity.py +88 -0
  116. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  117. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  118. package/hooks/validators/README.md +5 -6
  119. package/hooks/validators/__init__.py +2 -2
  120. package/hooks/validators/python_style_checks.py +298 -243
  121. package/hooks/validators/run_all_validators.py +6 -0
  122. package/hooks/validators/test_python_style_checks.py +278 -163
  123. package/package.json +2 -2
  124. package/rules/CLAUDE.md +2 -1
  125. package/rules/bdd.md +1 -1
  126. package/rules/nas-ssh-invocation.md +6 -4
  127. package/rules/no-justification-noise.md +61 -0
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +74 -78
  179. package/skills/orchestrator-refresh/SKILL.md +24 -12
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +56 -0
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
@@ -1,8 +1,16 @@
1
- """Shared helper for the CLAUDE_REVIEWS_DISABLED opt-out gate.
1
+ """Decide which PR reviewers run from the opt-out and opt-in env token lists.
2
2
 
3
- Both ``skills/bugteam/scripts/bugteam_preflight.py`` and
4
- ``_shared/pr-loop/scripts/preflight.py`` consume this helper so the parsing
5
- rules and disabled-token taxonomy live in exactly one place.
3
+ ::
4
+
5
+ enabled lists bugbot, disabled empty -> bugbot ok: runs
6
+ no lists set -> bugbot flag: off (default)
7
+ enabled lists bugbot, disabled bugbot -> bugbot flag: off (opt-out wins)
8
+ disabled empty -> bugteam ok: runs
9
+ disabled lists copilot -> copilot flag: off
10
+
11
+ Bugbot is off by default and runs only when the enabled list names it.
12
+ Bugteam and copilot run by default and stop only when the disabled list
13
+ names them; both lists parse case-insensitively and tolerate whitespace.
6
14
  """
7
15
 
8
16
  from __future__ import annotations
@@ -17,6 +25,7 @@ from pr_loop_shared_constants.reviews_disabled_constants import (
17
25
  CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
18
26
  CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
19
27
  CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR,
28
+ CLAUDE_REVIEWS_ENABLED_ENV_VAR_NAME,
20
29
  EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV,
21
30
  )
22
31
 
@@ -27,18 +36,24 @@ __all__ = [
27
36
  "CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN",
28
37
  "CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME",
29
38
  "CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR",
39
+ "CLAUDE_REVIEWS_ENABLED_ENV_VAR_NAME",
30
40
  "EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV",
31
41
  "is_bugbot_disabled_via_env",
42
+ "is_bugbot_opted_out_via_env",
32
43
  "is_bugteam_disabled_via_env",
33
44
  "is_copilot_disabled_via_env",
34
45
  "main",
35
46
  ]
36
47
 
37
48
 
38
- def _is_reviewer_disabled_via_env(reviewer_token: str) -> bool:
39
- """Check whether CLAUDE_REVIEWS_DISABLED lists the given reviewer token.
49
+ def _is_reviewer_listed_in_env(
50
+ environment_variable_name: str, reviewer_token: str
51
+ ) -> bool:
52
+ """Check whether an environment variable lists the given reviewer token.
40
53
 
41
54
  Args:
55
+ environment_variable_name: The environment variable to read, either
56
+ the reviews-disabled or the reviews-enabled variable name.
42
57
  reviewer_token: The reviewer token to look for, already lowercase
43
58
  (for example the bugteam or bugbot token constant).
44
59
 
@@ -46,16 +61,14 @@ def _is_reviewer_disabled_via_env(reviewer_token: str) -> bool:
46
61
  True when the env var contains ``reviewer_token`` as one of its
47
62
  comma-separated entries (case-insensitive, whitespace-tolerant).
48
63
  """
49
- reviews_disabled_token_separator = CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR
50
- disabled_reviewers_text = os.environ.get(CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME, "")
51
- all_disabled_tokens = frozenset(
64
+ reviews_token_separator = CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR
65
+ listed_reviewers_text = os.environ.get(environment_variable_name, "")
66
+ all_listed_tokens = frozenset(
52
67
  each_raw_token.strip().lower()
53
- for each_raw_token in disabled_reviewers_text.split(
54
- reviews_disabled_token_separator
55
- )
68
+ for each_raw_token in listed_reviewers_text.split(reviews_token_separator)
56
69
  if each_raw_token.strip()
57
70
  )
58
- return reviewer_token in all_disabled_tokens
71
+ return reviewer_token in all_listed_tokens
59
72
 
60
73
 
61
74
  def is_bugteam_disabled_via_env() -> bool:
@@ -64,16 +77,45 @@ def is_bugteam_disabled_via_env() -> bool:
64
77
  Returns:
65
78
  True when the env var lists the ``bugteam`` token.
66
79
  """
67
- return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN)
80
+ return _is_reviewer_listed_in_env(
81
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME, CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN
82
+ )
68
83
 
69
84
 
70
85
  def is_bugbot_disabled_via_env() -> bool:
86
+ """Check whether Cursor Bugbot is disabled for this run.
87
+
88
+ Cursor Bugbot is off by default. It runs only when
89
+ ``CLAUDE_REVIEWS_ENABLED`` lists ``bugbot``, and a ``bugbot`` token in
90
+ ``CLAUDE_REVIEWS_DISABLED`` forces it off even when the opt-in lists it.
91
+
92
+ Returns:
93
+ True when ``CLAUDE_REVIEWS_DISABLED`` lists ``bugbot`` or when
94
+ ``CLAUDE_REVIEWS_ENABLED`` does not list ``bugbot``.
95
+ """
96
+ is_opted_out = _is_reviewer_listed_in_env(
97
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME, CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN
98
+ )
99
+ is_opted_in = _is_reviewer_listed_in_env(
100
+ CLAUDE_REVIEWS_ENABLED_ENV_VAR_NAME, CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN
101
+ )
102
+ return is_opted_out or not is_opted_in
103
+
104
+
105
+ def is_bugbot_opted_out_via_env() -> bool:
71
106
  """Check whether CLAUDE_REVIEWS_DISABLED opts Cursor Bugbot out.
72
107
 
108
+ The opt-out forces Bugbot off even when ``CLAUDE_REVIEWS_ENABLED`` lists
109
+ ``bugbot``. This reports only the opt-out signal, where
110
+ ``is_bugbot_disabled_via_env`` also reports off for the default case in
111
+ which neither env var names ``bugbot``.
112
+
73
113
  Returns:
74
- True when the env var lists the ``bugbot`` token.
114
+ True when ``CLAUDE_REVIEWS_DISABLED`` lists the ``bugbot`` token.
75
115
  """
76
- return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN)
116
+ return _is_reviewer_listed_in_env(
117
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME, CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN
118
+ )
77
119
 
78
120
 
79
121
  def is_copilot_disabled_via_env() -> bool:
@@ -82,11 +124,13 @@ def is_copilot_disabled_via_env() -> bool:
82
124
  Returns:
83
125
  True when the env var lists the ``copilot`` token.
84
126
  """
85
- return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN)
127
+ return _is_reviewer_listed_in_env(
128
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME, CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN
129
+ )
86
130
 
87
131
 
88
132
  def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
89
- """Parse command-line arguments for the reviewer opt-out check.
133
+ """Parse command-line arguments for the reviewer opt-out and opt-in gate check.
90
134
 
91
135
  Args:
92
136
  all_argv: Argument list excluding the program name, typically
@@ -105,19 +149,19 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
105
149
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
106
150
  CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
107
151
  ],
108
- help="Reviewer token to test against CLAUDE_REVIEWS_DISABLED",
152
+ help="Reviewer token to test against the CLAUDE_REVIEWS_DISABLED / CLAUDE_REVIEWS_ENABLED gates",
109
153
  )
110
154
  return parser.parse_args(all_argv)
111
155
 
112
156
 
113
157
  def main(all_arguments: list[str]) -> int:
114
- """Exit 0 when the named reviewer is disabled via CLAUDE_REVIEWS_DISABLED.
158
+ """Exit 0 when the named reviewer is disabled for this run.
115
159
 
116
160
  Args:
117
161
  all_arguments: Argument list excluding the program name.
118
162
 
119
163
  Returns:
120
- 0 when the named reviewer is opted out by the env var, 1 otherwise.
164
+ 0 when the named reviewer is disabled for this run, 1 otherwise.
121
165
  """
122
166
  arguments = parse_arguments(all_arguments)
123
167
  disabled_checker_by_reviewer = {
@@ -1,22 +1,24 @@
1
1
  """Flag a prose term that names a code identifier a hair differently.
2
2
 
3
- A change adds the field ``premium_interactions`` while the same branch's docs
4
- call it the ``premium-request`` budget: one thing, two names, and a reader who
5
- searches one never finds the other.
3
+ A change adds the field ``premium_request_interactions`` while the same
4
+ branch's docs call it the ``premium-request-budget`` field: one thing, two
5
+ names, and a reader who searches one never finds the other.
6
6
 
7
7
  ::
8
8
 
9
- code adds: premium_interactions the identifier
10
- prose adds: the premium-request budget a hyphenated term
11
- flag: premium-request vs premium_interactions -- shared first word, tail diverges
12
- ok: premium-interactions -- exact hyphen form, agrees
13
- ok: read-only, test files, to a file -- English compound, plural, or stopword
9
+ code adds: premium_request_interactions the identifier
10
+ prose adds: the premium-request-budget field a hyphenated term
11
+ flag: premium-request-budget vs premium_request_interactions -- only the final word differs
12
+ ok: premium-request-interactions -- exact hyphen form, agrees
13
+ ok: premium-report-total vs premium_request_interactions -- an earlier word differs, ordinary prose
14
14
 
15
15
  The sweep reads a unified diff, collects the multi-word identifiers added on
16
16
  code lines, and scans each added prose line for a hyphen or space variant that
17
- shares an identifier's leading word but diverges after it. It spares ordinary
18
- prose: English compound tails, singular/plural forms, stopword windows, and
19
- words that are themselves tokens of another introduced identifier.
17
+ renames only the final word of an identifier: it agrees on every earlier word
18
+ and differs on the last. A term that diverges in any earlier word, or shares
19
+ only one leading word, is ordinary prose. It also spares English compound
20
+ tails, singular/plural forms, stopword windows, and words that are themselves
21
+ tokens of another introduced identifier.
20
22
  """
21
23
 
22
24
  import argparse
@@ -345,6 +347,47 @@ def _tuples_match_ignoring_plural(
345
347
  )
346
348
 
347
349
 
350
+ def _diverges_only_in_final_token(
351
+ candidate_tuple: IdentifierTuple, identifier_tuple: IdentifierTuple
352
+ ) -> bool:
353
+ """Return whether two equal-length tuples agree on every token but the last.
354
+
355
+ ::
356
+
357
+ candidate: ("premium", "request", "budget")
358
+ identifier: ("premium", "request", "interactions")
359
+ flag: every earlier token agrees, so the prose renames the final word
360
+ ok: ("premium", "report", "total") -- an earlier token diverges too
361
+
362
+ A near-miss renames a single trailing word of an identifier, so every token
363
+ before the last agrees by an exact or singular/plural match while the final
364
+ token differs. A tuple no longer than the two-token identifier minimum
365
+ cannot carry two agreeing leading tokens, so it never qualifies.
366
+
367
+ Args:
368
+ candidate_tuple: The prose term's lowercase token tuple.
369
+ identifier_tuple: An introduced identifier's lowercase token tuple.
370
+
371
+ Returns:
372
+ True when both tuples run longer than the two-token identifier minimum,
373
+ share the same length, agree by exact or singular/plural match on every
374
+ token but the last, and differ on the final token.
375
+ """
376
+ if (
377
+ len(candidate_tuple) <= MINIMUM_IDENTIFIER_TOKEN_COUNT
378
+ or len(candidate_tuple) != len(identifier_tuple)
379
+ ):
380
+ return False
381
+ if _tokens_are_plural_variants(candidate_tuple[-1], identifier_tuple[-1]):
382
+ return False
383
+ return all(
384
+ _tokens_are_plural_variants(each_candidate_token, each_identifier_token)
385
+ for each_candidate_token, each_identifier_token in zip(
386
+ candidate_tuple[:-1], identifier_tuple[:-1]
387
+ )
388
+ )
389
+
390
+
348
391
  def _word_is_identifier_vocabulary(
349
392
  prose_word: str, all_identifier_tokens: frozenset[str]
350
393
  ) -> bool:
@@ -391,16 +434,19 @@ def _near_miss_identifier(
391
434
  lines, used to spare prose built from real code vocabulary.
392
435
 
393
436
  Returns:
394
- The first identifier sharing the candidate's leading token and length
395
- but differing in a later token. None marks the candidate ordinary
396
- prose instead, on any of these grounds: it matches an identifier
397
- exactly; it shares no prefix with one; it ends in a common English
398
- compound tail word (``read-only``, ``data-driven``); it contains an
399
- English stopword (``to a``, ``each image``); it differs from the
400
- identifier only by a singular/plural form of one or more tokens
401
- (``test files`` against ``test_file``); or every diverging word is
402
- itself a token of some introduced identifier (``target box`` when
403
- ``box_height`` is also in the diff).
437
+ The first identifier the candidate renames in only its final token. Such a
438
+ candidate agrees on every earlier token, matches the identifier's
439
+ length, and differs on the last token. None marks the candidate
440
+ ordinary prose instead, on any of these grounds: it matches an
441
+ identifier exactly; it diverges in a token before the last
442
+ (``premium report total`` against ``premium_request_interactions``);
443
+ it holds no more than the two-token identifier minimum; it ends in a
444
+ common English compound tail word (``read-only``, ``data-driven``); it
445
+ contains an English stopword (``to a``, ``each image``); it differs
446
+ from the identifier only by a singular/plural form of one or more
447
+ tokens (``test files`` against ``test_file``); or every diverging word
448
+ is itself a token of some introduced identifier (``target width box``
449
+ when ``box_height`` is also in the diff).
404
450
  """
405
451
  if not candidate_tuple or candidate_tuple in all_identifier_tuples:
406
452
  return None
@@ -415,6 +461,8 @@ def _near_miss_identifier(
415
461
  continue
416
462
  if _tuples_match_ignoring_plural(each_identifier, candidate_tuple):
417
463
  continue
464
+ if not _diverges_only_in_final_token(candidate_tuple, each_identifier):
465
+ continue
418
466
  diverging_words = [
419
467
  each_word
420
468
  for each_word, each_token in zip(candidate_tuple, each_identifier)
@@ -32,7 +32,7 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
32
32
 
33
33
  ## Fixtures
34
34
 
35
- `fixtures/copilot_internal_user_jonecho.json` — a captured `gh api
35
+ `fixtures/copilot_internal_user_example.json` — a captured `gh api
36
36
  copilot_internal/user` response driving `test_copilot_quota.py`.
37
37
 
38
38
  ## Running
@@ -1,5 +1,5 @@
1
1
  {
2
- "login": "jonecho",
2
+ "login": "example-user",
3
3
  "access_type_sku": "plus_monthly_subscriber_quota",
4
4
  "assigned_date": "2026-05-09T14:08:29-04:00",
5
5
  "can_signup_for_limited": false,
@@ -336,7 +336,7 @@ def test_is_trust_entry_rejects_cross_project_path_suffix_collision() -> None:
336
336
  short_project_path = "/projects/foo"
337
337
  trust_prefix = "Trusted local workspace:"
338
338
  longer_unrelated_path_entry = (
339
- "Trusted local workspace: /Users/jon/projects/foo/.claude/** unrelated path"
339
+ "Trusted local workspace: /Users/example/projects/foo/.claude/** unrelated path"
340
340
  )
341
341
  assert (
342
342
  common_module.is_trust_entry_for_project(
@@ -337,11 +337,11 @@ def test_main_staged_mode_passes_when_only_terminology_findings_exist(
337
337
  commit_all_files(temporary_git_repository, "initial")
338
338
  write_file(
339
339
  temporary_git_repository / "quota.py",
340
- "first_count = 1\npremium_interactions = 1\n",
340
+ "first_count = 1\npremium_request_interactions = 1\n",
341
341
  )
342
342
  write_file(
343
343
  temporary_git_repository / "README.md",
344
- "The premium-request budget gates the run.\n",
344
+ "The premium-request-budget field gates the run.\n",
345
345
  )
346
346
  stage_file(temporary_git_repository, "quota.py")
347
347
  stage_file(temporary_git_repository, "README.md")
@@ -2043,6 +2043,88 @@ def test_run_staged_test_files_zero_when_staged_conftest_collects_no_tests(
2043
2043
  assert gate_module.run_staged_test_files(temporary_git_repository) == 0
2044
2044
 
2045
2045
 
2046
+ def test_staged_pytest_skipped_below_minimum_python(
2047
+ temporary_git_repository: Path,
2048
+ monkeypatch: pytest.MonkeyPatch,
2049
+ capsys: pytest.CaptureFixture[str],
2050
+ ) -> None:
2051
+ """Below the minimum Python, the staged pytest step is skipped, not run.
2052
+
2053
+ A staged test that would fail returns a zero exit code because the older
2054
+ interpreter cannot run its newer-API calls, so the environment gap does not
2055
+ block the commit.
2056
+ """
2057
+ write_file(
2058
+ temporary_git_repository / "test_needs_new_python.py",
2059
+ "def test_intentionally_fails() -> None:\n assert False\n",
2060
+ )
2061
+ stage_file(temporary_git_repository, "test_needs_new_python.py")
2062
+ monkeypatch.setattr(gate_module, "MINIMUM_STAGED_PYTEST_PYTHON_MAJOR", 99)
2063
+
2064
+ exit_code = gate_module._staged_pytest_exit_code_for_current_python(
2065
+ temporary_git_repository
2066
+ )
2067
+
2068
+ captured = capsys.readouterr()
2069
+ assert exit_code == 0
2070
+ assert "skipping the staged pytest step" in captured.err
2071
+
2072
+
2073
+ def test_staged_pytest_runs_at_or_above_minimum_python(
2074
+ temporary_git_repository: Path,
2075
+ monkeypatch: pytest.MonkeyPatch,
2076
+ ) -> None:
2077
+ """At or above the minimum Python, the staged pytest step runs and a failing
2078
+ staged test blocks with a non-zero exit code.
2079
+ """
2080
+ write_file(
2081
+ temporary_git_repository / "test_fails.py",
2082
+ "def test_intentionally_fails() -> None:\n assert False\n",
2083
+ )
2084
+ stage_file(temporary_git_repository, "test_fails.py")
2085
+ monkeypatch.setattr(gate_module, "MINIMUM_STAGED_PYTEST_PYTHON_MAJOR", 2)
2086
+
2087
+ exit_code = gate_module._staged_pytest_exit_code_for_current_python(
2088
+ temporary_git_repository
2089
+ )
2090
+
2091
+ assert exit_code != 0
2092
+
2093
+
2094
+ def test_main_staged_skips_pytest_but_runs_code_rules_below_minimum_python(
2095
+ temporary_git_repository: Path,
2096
+ monkeypatch: pytest.MonkeyPatch,
2097
+ capsys: pytest.CaptureFixture[str],
2098
+ ) -> None:
2099
+ """The version guard skips only the staged pytest step. A staged code-rules
2100
+ violation still blocks the commit, so the two checks stay independent.
2101
+ """
2102
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
2103
+ commit_all_files(temporary_git_repository, "initial")
2104
+ write_file(
2105
+ temporary_git_repository / "module.py",
2106
+ "first_count = 1\n"
2107
+ "def compute_total(operand: int) -> int:\n"
2108
+ " result = operand + 1\n"
2109
+ " return result\n",
2110
+ )
2111
+ write_file(
2112
+ temporary_git_repository / "test_fails.py",
2113
+ "def test_intentionally_fails() -> None:\n assert False\n",
2114
+ )
2115
+ stage_file(temporary_git_repository, "module.py")
2116
+ stage_file(temporary_git_repository, "test_fails.py")
2117
+ monkeypatch.setattr(gate_module, "MINIMUM_STAGED_PYTEST_PYTHON_MAJOR", 99)
2118
+ monkeypatch.chdir(temporary_git_repository)
2119
+
2120
+ exit_code = gate_module.main(["--staged"])
2121
+
2122
+ captured = capsys.readouterr()
2123
+ assert exit_code == 1
2124
+ assert "skipping the staged pytest step" in captured.err
2125
+ assert "result" in captured.err
2126
+
2127
+
2046
2128
  def test_hunk_header_pattern_captures_new_start_and_count() -> None:
2047
2129
  header_match = gate_module.hunk_header_pattern().match("@@ -12,3 +45,6 @@ def scope")
2048
2130
  assert header_match is not None
@@ -2578,3 +2660,211 @@ def test_run_staged_test_files_fails_when_one_group_fails(
2578
2660
  assert exit_code != 0
2579
2661
  captured_error = capsys.readouterr().err
2580
2662
  assert "failing_skill" in captured_error
2663
+
2664
+
2665
+ def test_resolve_gate_python_executable_prefers_env_var_over_venv(
2666
+ tmp_path: Path,
2667
+ monkeypatch: pytest.MonkeyPatch,
2668
+ ) -> None:
2669
+ relative_segments = (
2670
+ gate_module.ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
2671
+ if os.name == "nt"
2672
+ else gate_module.ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
2673
+ )
2674
+ venv_python = tmp_path.joinpath(".venv", *relative_segments)
2675
+ venv_python.parent.mkdir(parents=True)
2676
+ venv_python.write_text("", encoding="utf-8")
2677
+ monkeypatch.setenv("CODE_RULES_GATE_PYTHON", "/configured/python")
2678
+
2679
+ resolved = gate_module._resolve_gate_python_executable(tmp_path)
2680
+
2681
+ assert resolved == "/configured/python"
2682
+
2683
+
2684
+ def test_resolve_gate_python_executable_falls_back_to_project_venv(
2685
+ tmp_path: Path,
2686
+ monkeypatch: pytest.MonkeyPatch,
2687
+ ) -> None:
2688
+ monkeypatch.delenv("CODE_RULES_GATE_PYTHON", raising=False)
2689
+ relative_segments = (
2690
+ gate_module.ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
2691
+ if os.name == "nt"
2692
+ else gate_module.ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
2693
+ )
2694
+ venv_python = tmp_path.joinpath(".venv", *relative_segments)
2695
+ venv_python.parent.mkdir(parents=True)
2696
+ venv_python.write_text("", encoding="utf-8")
2697
+
2698
+ resolved = gate_module._resolve_gate_python_executable(tmp_path)
2699
+
2700
+ assert resolved == str(venv_python)
2701
+
2702
+
2703
+ def test_resolve_gate_python_executable_falls_back_to_sys_executable(
2704
+ tmp_path: Path,
2705
+ monkeypatch: pytest.MonkeyPatch,
2706
+ ) -> None:
2707
+ monkeypatch.delenv("CODE_RULES_GATE_PYTHON", raising=False)
2708
+
2709
+ resolved = gate_module._resolve_gate_python_executable(tmp_path)
2710
+
2711
+ assert resolved == sys.executable
2712
+
2713
+
2714
+ def test_staged_pytest_environment_prepends_configured_pythonpath(
2715
+ monkeypatch: pytest.MonkeyPatch,
2716
+ ) -> None:
2717
+ monkeypatch.setenv("CODE_RULES_GATE_PYTHONPATH", "/configured/site-packages")
2718
+ monkeypatch.setenv("PYTHONPATH", "/already/on/path")
2719
+
2720
+ environment = gate_module._staged_pytest_environment()
2721
+
2722
+ assert environment["PYTHONPATH"] == os.pathsep.join(
2723
+ ["/configured/site-packages", "/already/on/path"]
2724
+ )
2725
+
2726
+
2727
+ def _record_pytest_invocations(
2728
+ monkeypatch: pytest.MonkeyPatch, *, all_exit_codes: list[int]
2729
+ ) -> list[list[str]]:
2730
+ """Patch subprocess.run to record each pytest argv and return staged exit codes."""
2731
+ all_recorded_commands: list[list[str]] = []
2732
+ all_remaining_exit_codes = list(all_exit_codes)
2733
+
2734
+ def _fake_subprocess_run(
2735
+ all_command: list[str], **_keyword_arguments: object
2736
+ ) -> subprocess.CompletedProcess[bytes]:
2737
+ all_recorded_commands.append(list(all_command))
2738
+ exit_code = all_remaining_exit_codes.pop(0) if all_remaining_exit_codes else 0
2739
+ return subprocess.CompletedProcess(all_command, exit_code, b"", b"")
2740
+
2741
+ monkeypatch.setattr(gate_module.subprocess, "run", _fake_subprocess_run)
2742
+ return all_recorded_commands
2743
+
2744
+
2745
+ def _staged_test_paths_under(group_root: Path, path_count: int) -> list[Path]:
2746
+ return [
2747
+ group_root
2748
+ / "tests"
2749
+ / "subpackage_with_a_realistically_long_directory_name"
2750
+ / f"test_module_with_a_realistically_long_name_{each_index:04d}.py"
2751
+ for each_index in range(path_count)
2752
+ ]
2753
+
2754
+
2755
+ def _path_arguments_of(all_pytest_command: list[str]) -> list[str]:
2756
+ fixed_prefix_length = len(gate_module.ALL_PYTEST_MODULE_INVOCATION) + 1
2757
+ return all_pytest_command[fixed_prefix_length:]
2758
+
2759
+
2760
+ def test_run_pytest_for_group_batches_a_large_staged_set_under_the_budget(
2761
+ tmp_path: Path,
2762
+ monkeypatch: pytest.MonkeyPatch,
2763
+ ) -> None:
2764
+ """WinError 206: a staged set whose joined argv passes Windows' 32767-character
2765
+ command-line limit crashed the gate and denied the commit. The paths must be
2766
+ split across several pytest invocations, none of them over the budget.
2767
+ """
2768
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2769
+ all_test_paths = _staged_test_paths_under(tmp_path, 800)
2770
+
2771
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2772
+
2773
+ assert exit_code == 0
2774
+ assert len(all_recorded_commands) > 1, (
2775
+ "a staged set wider than the command-line budget must be split across "
2776
+ "several pytest invocations rather than passed in one oversized argv"
2777
+ )
2778
+ for each_command in all_recorded_commands:
2779
+ assert (
2780
+ len(" ".join(each_command))
2781
+ <= gate_module.MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS
2782
+ )
2783
+ all_batched_arguments = [
2784
+ each_argument
2785
+ for each_command in all_recorded_commands
2786
+ for each_argument in _path_arguments_of(each_command)
2787
+ ]
2788
+ assert len(all_batched_arguments) == len(all_test_paths)
2789
+
2790
+
2791
+ def test_run_pytest_for_group_passes_paths_relative_to_the_group_root(
2792
+ tmp_path: Path,
2793
+ monkeypatch: pytest.MonkeyPatch,
2794
+ ) -> None:
2795
+ """group_root is already the subprocess cwd, so each path argument is passed
2796
+ relative to it - the shortening that keeps a large staged set under the limit.
2797
+ """
2798
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2799
+ test_path = tmp_path / "tests" / "test_relative.py"
2800
+
2801
+ gate_module._run_pytest_for_group(tmp_path, [test_path], tmp_path)
2802
+
2803
+ assert _path_arguments_of(all_recorded_commands[0]) == [
2804
+ str(Path("tests") / "test_relative.py")
2805
+ ]
2806
+
2807
+
2808
+ def test_run_pytest_for_group_passes_an_outside_path_absolute(
2809
+ tmp_path: Path,
2810
+ monkeypatch: pytest.MonkeyPatch,
2811
+ ) -> None:
2812
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2813
+ group_root = tmp_path / "group"
2814
+ outside_path = tmp_path / "elsewhere" / "test_outside.py"
2815
+
2816
+ gate_module._run_pytest_for_group(group_root, [outside_path], tmp_path)
2817
+
2818
+ assert _path_arguments_of(all_recorded_commands[0]) == [str(outside_path)]
2819
+
2820
+
2821
+ def test_run_pytest_for_group_runs_one_invocation_for_a_small_staged_set(
2822
+ tmp_path: Path,
2823
+ monkeypatch: pytest.MonkeyPatch,
2824
+ ) -> None:
2825
+ all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
2826
+ all_test_paths = _staged_test_paths_under(tmp_path, 3)
2827
+
2828
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2829
+
2830
+ assert exit_code == 0
2831
+ assert len(all_recorded_commands) == 1, (
2832
+ "a staged set that fits the budget must behave exactly as before: "
2833
+ "one pytest invocation carrying every path"
2834
+ )
2835
+
2836
+
2837
+ def test_run_pytest_for_group_surfaces_the_first_failing_batch_exit_code(
2838
+ tmp_path: Path,
2839
+ monkeypatch: pytest.MonkeyPatch,
2840
+ ) -> None:
2841
+ """A failure in any batch must surface, and every batch must still run so the
2842
+ operator sees the whole staged-test picture rather than the first failure alone.
2843
+ """
2844
+ all_recorded_commands = _record_pytest_invocations(
2845
+ monkeypatch, all_exit_codes=[0, 1, 2]
2846
+ )
2847
+ all_test_paths = _staged_test_paths_under(tmp_path, 800)
2848
+
2849
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2850
+
2851
+ assert exit_code == 1
2852
+ assert len(all_recorded_commands) >= 3
2853
+
2854
+
2855
+ def test_run_pytest_for_group_treats_no_tests_collected_as_a_pass_per_batch(
2856
+ tmp_path: Path,
2857
+ monkeypatch: pytest.MonkeyPatch,
2858
+ ) -> None:
2859
+ _record_pytest_invocations(
2860
+ monkeypatch,
2861
+ all_exit_codes=[gate_module.PYTEST_NO_TESTS_COLLECTED_EXIT_CODE, 0],
2862
+ )
2863
+ all_test_paths = _staged_test_paths_under(tmp_path, 800)
2864
+
2865
+ exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
2866
+
2867
+ assert exit_code == 0, (
2868
+ "a batch that collects no tests is not a failure; only a real pytest "
2869
+ "failure exit code may block the commit"
2870
+ )