claude-dev-env 1.82.0 → 1.83.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 (67) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/README.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  7. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  11. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  12. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  13. package/hooks/blocking/CLAUDE.md +2 -0
  14. package/hooks/blocking/code_rules_constants_config.py +159 -1
  15. package/hooks/blocking/code_rules_docstrings.py +312 -9
  16. package/hooks/blocking/code_rules_enforcer.py +29 -0
  17. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  18. package/hooks/blocking/code_rules_naming_collection.py +141 -0
  19. package/hooks/blocking/code_rules_string_magic.py +68 -0
  20. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  21. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  22. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  23. package/hooks/blocking/state_description_blocker.py +96 -5
  24. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  25. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  27. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  28. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  29. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  30. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  31. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  32. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  33. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  35. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  36. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  37. package/hooks/blocking/test_state_description_blocker.py +135 -0
  38. package/hooks/hooks.json +5 -0
  39. package/hooks/hooks_constants/CLAUDE.md +3 -1
  40. package/hooks/hooks_constants/blocking_check_limits.py +43 -0
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  43. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  44. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  45. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  46. package/package.json +1 -1
  47. package/rules/CLAUDE.md +4 -1
  48. package/rules/claude-md-orphan-file.md +5 -0
  49. package/rules/docstring-prose-matches-implementation.md +10 -1
  50. package/rules/env-var-table-code-drift.md +5 -0
  51. package/rules/es-exe-file-search.md +17 -0
  52. package/rules/no-historical-clutter.md +12 -1
  53. package/rules/orphan-css-class.md +5 -0
  54. package/rules/package-inventory-stale-entry.md +10 -0
  55. package/rules/paired-test-coverage.md +5 -0
  56. package/rules/plain-illustrative-docstrings.md +5 -0
  57. package/rules/verify-before-asking.md +7 -0
  58. package/rules/verify-runtime-state.md +40 -0
  59. package/rules/windows-filesystem-safe.md +8 -0
  60. package/rules/workers-done-before-complete.md +33 -0
  61. package/rules/workflow-substitution-slots.md +5 -0
  62. package/skills/CLAUDE.md +1 -1
  63. package/skills/autoconverge/SKILL.md +10 -4
  64. package/skills/autoconverge/workflow/converge.contract.test.mjs +69 -0
  65. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +54 -18
  66. package/skills/autoconverge/workflow/converge.mjs +97 -33
  67. package/skills/everything-search/SKILL.md +5 -0
package/CLAUDE.md CHANGED
@@ -115,30 +115,33 @@ Before any coding task, call the `initial_instructions` tool to load the Serena
115
115
 
116
116
  ### Tool hierarchy for code navigation
117
117
  1. **Serena** — symbol-level navigation (declarations, references, implementations, rename)
118
- 2. **Everything** (`everything_search`) — file-system search by name/path/extension
118
+ 2. **es.exe** — file-system search by name/path/extension/size/date (Everything CLI)
119
119
  3. **Grep/Glob** — content and pattern matching
120
120
 
121
- ## Everything Search (MCP Tool)
121
+ ## Everything Search (es.exe CLI)
122
122
 
123
- This machine has **Everything (voidtools)** running with an HTTP server on port 54321.
124
- The `everything_search` MCP tool is available in every session.
123
+ Search files by name, path, extension, size, or date with the Everything command-line tool `es.exe`. It reads the same live index the desktop app keeps, so results return instantly.
125
124
 
126
- ### Use Everything for file-system searches
127
- Use `everything_search` for finding files by name, path, extension, size, or date. For content searches, use Grep Everything's `content:` search is a fallback when Grep returns nothing.
125
+ ### Invocation and scope tokens
126
+ Run `es.exe` with a scoped query a project path, an `ext:`/`dm:`/`size:` filter, or a name pattern. The `es_exe_path_rewriter` hook resolves a `{project-name}` placeholder or a bare registry key from `~/.claude/project-paths.json` into its quoted absolute path before the command runs (it allows and rewrites, never blocks).
127
+
128
+ ### Hard limits
129
+ - Scope every search. A bare whole-drive scan or a network-share sweep is out of bounds.
128
130
 
129
131
  ### Fallback order
130
- 1. **Everything** (`everything_search`) — file-system search by name/path/extension/size/date, and content search
131
- 2. **Grep** — complex regex content searches if Everything's `content:` returns nothing
132
- 3. **Glob** — precise relative-path pattern matching within the current project
132
+ 1. **es.exe** — file-system search by name/path/extension/size/date
133
+ 2. **Debug** — try to find out why es.exe isn't working, and then prompt user for next-steps if you can't self-heal.
134
+ 3. **Grep** — file-content search (Grep owns content)
135
+ 4. **Glob** — relative-path pattern matching within the current project
133
136
 
134
137
  ### Search syntax quick reference
135
- - `ext:py` — find by extension (multiple: `ext:ts;js`)
138
+ - `ext:py` — find by extension (multiple: `ext:ts | ext:js`)
136
139
  - `path:src\components` — match against full path
137
- - `count:10` — limit number of results to 10
138
140
  - `*.config.*` — wildcards
139
141
  - `size:>10mb` — size filter
140
142
  - `dm:today` / `dm:thisweek` — date modified filter
141
- - `content:keyword` — search inside file contents
142
- - `parent:node_modules package.json` — match parent folder
143
+ - `-n 50` — limit results; `-sort dm` — sort by date modified
143
144
  - `foo bar` — AND, `foo | bar` — OR, `!foo` — NOT
144
145
  - `"exact phrase"` — literal match
146
+
147
+ Full operator reference: `skills/everything-search/SKILL.md`.
@@ -15,6 +15,7 @@ Python scripts invoked at runtime by the PR-loop skills. Each script is a standa
15
15
  | `terminology_sweep.py` | Flags a prose term that near-misses an identifier introduced on added code lines of a unified diff (shared leading word, divergent tail) |
16
16
  | `reviews_disabled.py` | Shared helper for the `CLAUDE_REVIEWS_DISABLED` opt-out gate; parses the env-var token to find which reviewer types are suppressed |
17
17
  | `copilot_quota.py` | Copilot premium-request quota pre-check: resolves a configured GitHub account, reads its remaining `premium_interactions` quota via `gh api copilot_internal/user`, and exits 0 (run Copilot) or non-zero (skip: out of quota, API down, or no account configured) |
18
+ | `reviewer_availability.py` | Unified reviewer-availability entry point for Copilot and Bugbot: reuses `copilot_quota.py` and `reviews_disabled.py` and exits 0 when the named `--reviewer` may be spawned, non-zero when it is opted out or (for Copilot) out of quota |
18
19
  | `fix_hookspath.py` | Repairs a malformed `core.hooksPath` global git config entry |
19
20
  | `_claude_permissions_common.py` | Internal helpers shared by the grant/revoke scripts: atomic settings.json writes, list mutation, path helpers |
20
21
 
@@ -7,6 +7,7 @@ Runnable helpers used by **bugteam**, **qbug**, **pr-converge**, and related ski
7
7
  | File | Purpose |
8
8
  | --- | --- |
9
9
  | `preflight.py` | Local checks before a PR-loop run (pytest discovery, optional pre-commit, hooksPath sanity). |
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. |
10
11
  | `code_rules_gate.py` | CODE_RULES gate over PR-scoped diffs (`--base`, staged-only, path filters). |
11
12
  | `fix_hookspath.py` | Repair `core.hooksPath` when it does not point at the packaged git-hooks tree. |
12
13
  | `grant_project_claude_permissions.py` / `revoke_project_claude_permissions.py` | Claude Code permission JSON helpers used during publish-style flows. |
@@ -17,6 +17,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
17
17
  | `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
18
18
  | `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
19
19
  | `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, and the finding-message template |
20
+ | `reviewer_availability_constants.py` | `reviewer_availability.py` — the available/down exit codes for the unified reviewer-availability pre-check |
20
21
  | `__init__.py` | Empty package marker |
21
22
 
22
23
  ## Convention
@@ -0,0 +1,12 @@
1
+ """Configuration constants for the unified reviewer-availability pre-check.
2
+
3
+ The down code is 3 so a deliberate reviewer-down report stays distinguishable
4
+ from a generic interpreter crash (exit 1) and an argparse usage error
5
+ (exit 2). A caller that gates on the down code treats any other non-zero exit
6
+ as a broken check, not a down reviewer, and fails open.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ EXIT_CODE_REVIEWER_AVAILABLE: int = 0
12
+ EXIT_CODE_REVIEWER_DOWN: int = 3
@@ -24,8 +24,6 @@ HYPHENATED_PROSE_TOKEN_PATTERN: re.Pattern[str] = re.compile(
24
24
  r"\b[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+\b"
25
25
  )
26
26
 
27
- PROSE_WORD_PATTERN: re.Pattern[str] = re.compile(r"[A-Za-z][A-Za-z0-9]*")
28
-
29
27
  STRING_LITERAL_CONTENT_PATTERN: re.Pattern[str] = re.compile(
30
28
  r"\"([^\"]*)\"|'([^']*)'|`([^`]*)`"
31
29
  )
@@ -0,0 +1,182 @@
1
+ """Unified reviewer-availability pre-check for GitHub Copilot and Cursor Bugbot.
2
+
3
+ Run this before either reviewer step spawns. It answers one question: is the
4
+ named reviewer available to run right now. It reuses two existing checks
5
+ instead of re-implementing their rules. The ``CLAUDE_REVIEWS_DISABLED``
6
+ opt-out gate lives in ``reviews_disabled.py``. The Copilot premium-interaction
7
+ quota pre-check lives in ``copilot_quota.py``.
8
+
9
+ Copilot counts as down in two cases: it is opted out via
10
+ ``CLAUDE_REVIEWS_DISABLED``, or its quota pre-check returns anything other
11
+ than quota available (out of quota, the quota API or account down, or no
12
+ account configured).
13
+
14
+ Bugbot carries no quota or availability API. It counts as down only when it
15
+ is opted out via ``CLAUDE_REVIEWS_DISABLED``. A genuine runtime outage shows
16
+ up later as a poll timeout, not here.
17
+
18
+ The exit code tells the caller what to do. Exit 0 means the reviewer is
19
+ available and may be spawned. The documented down code (3) means skip it. Any
20
+ other non-zero exit is a broken check — an interpreter crash or a usage
21
+ error — not a down report, so a gating caller fails open on it. Every path
22
+ prints one line: available lines go to stdout, down lines go to stderr.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import sys
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ from copilot_quota import (
33
+ COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH,
34
+ EXIT_CODE_QUOTA_AVAILABLE,
35
+ evaluate_copilot_quota,
36
+ )
37
+ from pr_loop_shared_constants.reviewer_availability_constants import (
38
+ EXIT_CODE_REVIEWER_AVAILABLE,
39
+ EXIT_CODE_REVIEWER_DOWN,
40
+ )
41
+ from pr_loop_shared_constants.reviews_disabled_constants import (
42
+ CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
43
+ CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
44
+ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
45
+ )
46
+ from reviews_disabled import is_bugbot_disabled_via_env, is_copilot_disabled_via_env
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ReviewerAvailability:
51
+ """One reviewer-availability outcome: the exit code and the single log line.
52
+
53
+ The exit code tells the caller what to do, where 0 means the reviewer may
54
+ be spawned and the documented down code (3) means skip it. The CLI prints
55
+ the message on one line, to stdout when available and to stderr when down.
56
+ """
57
+
58
+ exit_code: int
59
+ message: str
60
+
61
+
62
+ def _evaluate_copilot_availability(env_file_path: Path) -> ReviewerAvailability:
63
+ """Decide whether GitHub Copilot is available to spawn.
64
+
65
+ Args:
66
+ env_file_path: The ``.env`` file the quota pre-check consults for the
67
+ configured account when neither the flag nor the environment
68
+ variable names one.
69
+
70
+ Returns:
71
+ A ReviewerAvailability that is down when Copilot is opted out via
72
+ ``CLAUDE_REVIEWS_DISABLED`` or the quota pre-check reports anything
73
+ other than quota available, and available otherwise.
74
+ """
75
+ if is_copilot_disabled_via_env():
76
+ return ReviewerAvailability(
77
+ EXIT_CODE_REVIEWER_DOWN,
78
+ f"reviewer-availability: copilot is disabled via "
79
+ f"{CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME} — skipping.",
80
+ )
81
+ quota_decision = evaluate_copilot_quota(cli_account=None, env_file_path=env_file_path)
82
+ if quota_decision.exit_code != EXIT_CODE_QUOTA_AVAILABLE:
83
+ return ReviewerAvailability(EXIT_CODE_REVIEWER_DOWN, quota_decision.message)
84
+ return ReviewerAvailability(EXIT_CODE_REVIEWER_AVAILABLE, quota_decision.message)
85
+
86
+
87
+ def _evaluate_bugbot_availability() -> ReviewerAvailability:
88
+ """Decide whether Cursor Bugbot is available to spawn.
89
+
90
+ Bugbot carries no quota or availability API, so this checks only the
91
+ deterministic ``CLAUDE_REVIEWS_DISABLED`` opt-out.
92
+
93
+ Returns:
94
+ A ReviewerAvailability that is down when Bugbot is opted out via
95
+ ``CLAUDE_REVIEWS_DISABLED``, and available otherwise.
96
+ """
97
+ if is_bugbot_disabled_via_env():
98
+ return ReviewerAvailability(
99
+ EXIT_CODE_REVIEWER_DOWN,
100
+ f"reviewer-availability: bugbot is disabled via "
101
+ f"{CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME} — skipping.",
102
+ )
103
+ return ReviewerAvailability(
104
+ EXIT_CODE_REVIEWER_AVAILABLE,
105
+ "reviewer-availability: bugbot is available.",
106
+ )
107
+
108
+
109
+ def evaluate_reviewer_availability(
110
+ reviewer_token: str, env_file_path: Path
111
+ ) -> ReviewerAvailability:
112
+ """Decide whether the named reviewer is available to spawn.
113
+
114
+ Args:
115
+ reviewer_token: Either the Copilot or the Bugbot reviewer token.
116
+ env_file_path: The ``.env`` file the Copilot quota pre-check consults
117
+ for the configured account.
118
+
119
+ Returns:
120
+ The ReviewerAvailability for the named reviewer.
121
+
122
+ Raises:
123
+ ValueError: When reviewer_token names neither the Copilot nor the
124
+ Bugbot reviewer token.
125
+ """
126
+ if reviewer_token == CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN:
127
+ return _evaluate_copilot_availability(env_file_path)
128
+ if reviewer_token == CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN:
129
+ return _evaluate_bugbot_availability()
130
+ raise ValueError(f"unknown reviewer token: {reviewer_token}")
131
+
132
+
133
+ def _parse_arguments(all_argv: list[str]) -> argparse.Namespace:
134
+ """Parse command-line arguments for the reviewer-availability check.
135
+
136
+ Args:
137
+ all_argv: Argument list excluding the program name, typically
138
+ ``sys.argv[1:]``.
139
+
140
+ Returns:
141
+ Namespace exposing a ``reviewer`` attribute constrained to the
142
+ Copilot and Bugbot reviewer tokens.
143
+ """
144
+ parser = argparse.ArgumentParser(description=__doc__)
145
+ parser.add_argument(
146
+ "--reviewer",
147
+ required=True,
148
+ choices=[
149
+ CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
150
+ CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
151
+ ],
152
+ help="Reviewer to check availability for",
153
+ )
154
+ return parser.parse_args(all_argv)
155
+
156
+
157
+ def main(all_arguments: list[str]) -> int:
158
+ """Run the reviewer-availability check end-to-end and print its decision.
159
+
160
+ Args:
161
+ all_arguments: Argument list excluding the program name.
162
+
163
+ Returns:
164
+ 0 when the named reviewer is available to spawn, the documented
165
+ reviewer-down exit code when it is down.
166
+ """
167
+ arguments = _parse_arguments(all_arguments)
168
+ availability = evaluate_reviewer_availability(
169
+ reviewer_token=arguments.reviewer,
170
+ env_file_path=COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH,
171
+ )
172
+ message_stream = (
173
+ sys.stdout
174
+ if availability.exit_code == EXIT_CODE_REVIEWER_AVAILABLE
175
+ else sys.stderr
176
+ )
177
+ print(availability.message, file=message_stream)
178
+ return availability.exit_code
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main(sys.argv[1:]))
@@ -7,11 +7,18 @@ finds the other.
7
7
 
8
8
  This sweep reads a unified diff and collects every multi-word identifier added
9
9
  on code lines. Then it scans each added prose line — a Markdown line, or a
10
- comment, docstring, or string inside a code file. It flags a hyphen or space
11
- variant that shares an identifier's leading word but diverges in the tail. Each
10
+ comment, docstring, or string inside a code file. It flags a hyphenated term
11
+ that shares an identifier's leading word but diverges in the tail. Each
12
12
  near-miss prints as one ``file:line`` finding, and the run exits non-zero when
13
13
  any finding remains, so a commit gate can block on it.
14
14
 
15
+ Only hyphenated terms are candidates. A hyphen marks a deliberate compound —
16
+ the author bound the words into one term — so a divergent tail there is a real
17
+ naming drift. A bare spaced word run is ordinary prose: windowing every
18
+ sentence that starts with an identifier's first word ("each attempt", "file
19
+ exists") floods a vocabulary-dense change with false findings and gives the
20
+ gate nothing actionable.
21
+
15
22
  A shared leading token alone is too weak a signal: ordinary English compounds
16
23
  such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
17
24
  (``read_config``, ``data_source``) and would block a commit falsely. To keep the
@@ -56,7 +63,6 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
56
63
  JSDOC_CONTINUATION_MARKER,
57
64
  MARKDOWN_FILE_EXTENSION,
58
65
  MINIMUM_IDENTIFIER_TOKEN_COUNT,
59
- PROSE_WORD_PATTERN,
60
66
  PYTHON_COMMENT_MARKER,
61
67
  SNAKE_CASE_IDENTIFIER_PATTERN,
62
68
  STRING_LITERAL_CONTENT_PATTERN,
@@ -234,35 +240,6 @@ def _hyphenated_candidates(fragment: str) -> list[tuple[str, IdentifierTuple]]:
234
240
  return all_candidates
235
241
 
236
242
 
237
- def _spaced_candidates(
238
- fragment: str,
239
- identifiers_by_first_token: dict[str, list[IdentifierTuple]],
240
- ) -> list[tuple[str, IdentifierTuple]]:
241
- """Return spaced word windows whose first word matches an identifier prefix.
242
-
243
- Only a window anchored at a known identifier prefix and sized to one of that
244
- prefix's identifiers is returned, which bounds the windows to the shapes the
245
- near-miss check can act on.
246
-
247
- Args:
248
- fragment: The prose fragment to scan.
249
- identifiers_by_first_token: Identifier tuples grouped by leading token.
250
-
251
- Returns:
252
- ``(display, token_tuple)`` pairs for each candidate window.
253
- """
254
- all_words = [
255
- each_word.lower() for each_word in PROSE_WORD_PATTERN.findall(fragment)
256
- ]
257
- all_candidates: list[tuple[str, IdentifierTuple]] = []
258
- for each_index, each_word in enumerate(all_words):
259
- for each_identifier in identifiers_by_first_token.get(each_word, []):
260
- window = tuple(all_words[each_index : each_index + len(each_identifier)])
261
- if len(window) == len(each_identifier):
262
- all_candidates.append((" ".join(window), window))
263
- return all_candidates
264
-
265
-
266
243
  def _tokens_are_plural_variants(first_word: str, second_word: str) -> bool:
267
244
  """Return whether two words are singular/plural forms of one word.
268
245
 
@@ -348,7 +325,6 @@ def _findings_for_line(
348
325
  reported_tuples: set[IdentifierTuple] = set()
349
326
  for each_fragment in _prose_fragments(file_path, line_text):
350
327
  all_candidates = _hyphenated_candidates(each_fragment)
351
- all_candidates += _spaced_candidates(each_fragment, identifiers_by_first_token)
352
328
  for each_display, each_tuple in all_candidates:
353
329
  matched_identifier = _near_miss_identifier(
354
330
  each_tuple, all_identifier_tuples, identifiers_by_first_token
@@ -24,6 +24,8 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
24
24
  | `test_reviews_disabled.py` | `reviews_disabled.py` opt-out gate parsing |
25
25
  | `test_copilot_quota.py` | `copilot_quota.py` end-to-end: account resolution, premium-quota classification, exit codes, and skip logging |
26
26
  | `test_copilot_quota_constants.py` | `pr_loop_shared_constants/copilot_quota_constants.py` |
27
+ | `test_reviewer_availability.py` | `reviewer_availability.py` end-to-end: Copilot and Bugbot availability, opt-out via `CLAUDE_REVIEWS_DISABLED`, and every Copilot quota outcome |
28
+ | `test_reviewer_availability_constants.py` | `pr_loop_shared_constants/reviewer_availability_constants.py` |
27
29
  | `test_revoke_project_claude_permissions.py` | `revoke_project_claude_permissions.py` end-to-end |
28
30
  | `test_agent_config_carveout.py` | Agent-config deny-rule carve-out logic |
29
31
  | `conftest.py` | Shared pytest fixtures |
@@ -0,0 +1,159 @@
1
+ """Direct unit tests for the unified reviewer_availability pre-check.
2
+
3
+ Every case drives the production ``main`` path through
4
+ ``evaluate_reviewer_availability``. Copilot cases stub only the ``gh``
5
+ subprocess boundary inside ``copilot_quota`` (``_run_gh``), reusing the same
6
+ captured fixture ``test_copilot_quota.py`` drives. Opt-out cases set or clear
7
+ ``CLAUDE_REVIEWS_DISABLED`` directly.
8
+ """
9
+
10
+ import importlib.util
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ import pytest
17
+
18
+
19
+ def _load_module(module_name: str) -> ModuleType:
20
+ scripts_directory = Path(__file__).parent.parent
21
+ if str(scripts_directory) not in sys.path:
22
+ sys.path.insert(0, str(scripts_directory))
23
+ module_path = scripts_directory / f"{module_name}.py"
24
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
25
+ assert specification is not None
26
+ assert specification.loader is not None
27
+ module = importlib.util.module_from_spec(specification)
28
+ sys.modules[specification.name] = module
29
+ specification.loader.exec_module(module)
30
+ return module
31
+
32
+
33
+ copilot_quota = _load_module("copilot_quota")
34
+ reviewer_availability = _load_module("reviewer_availability")
35
+
36
+ FIXTURE_PATH = Path(__file__).parent / "fixtures" / "copilot_internal_user_jonecho.json"
37
+ AVAILABLE_USER_JSON = FIXTURE_PATH.read_text(encoding="utf-8")
38
+ FAKE_TOKEN_RESULT = (0, "ghp_faketoken_value\n")
39
+
40
+
41
+ def _exhausted_user_json() -> str:
42
+ user = json.loads(AVAILABLE_USER_JSON)
43
+ premium = user["quota_snapshots"]["premium_interactions"]
44
+ premium["remaining"] = 0
45
+ premium["quota_remaining"] = 0.0
46
+ premium["percent_remaining"] = 0.0
47
+ return json.dumps(user)
48
+
49
+
50
+ def _gh_stub(token_result: tuple[int, str], api_result: tuple[int, str]):
51
+ def _fake_run_gh(
52
+ command_arguments: list[str],
53
+ extra_environment: dict[str, str] | None = None,
54
+ ) -> tuple[int, str]:
55
+ if command_arguments and command_arguments[0] == "auth":
56
+ return token_result
57
+ if command_arguments and command_arguments[0] == "api":
58
+ return api_result
59
+ raise AssertionError(f"unexpected gh command {command_arguments}")
60
+
61
+ return _fake_run_gh
62
+
63
+
64
+ @pytest.fixture(autouse=True)
65
+ def _isolate_reviewer_environment(
66
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
67
+ ) -> None:
68
+ monkeypatch.delenv("COPILOT_QUOTA_ACCOUNT", raising=False)
69
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
70
+ monkeypatch.setattr(
71
+ reviewer_availability, "COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH", tmp_path / ".env"
72
+ )
73
+
74
+
75
+ def test_main_reports_copilot_available_when_quota_confirmed_available(
76
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
77
+ ) -> None:
78
+ monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
79
+ monkeypatch.setattr(
80
+ copilot_quota, "_run_gh", _gh_stub(FAKE_TOKEN_RESULT, (0, AVAILABLE_USER_JSON))
81
+ )
82
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
83
+ captured = capsys.readouterr()
84
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_AVAILABLE
85
+ assert "running Copilot" in captured.out
86
+
87
+
88
+ def test_main_reports_copilot_down_when_opted_out_via_env(
89
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
90
+ ) -> None:
91
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
92
+
93
+ def _fail_if_called(
94
+ command_arguments: list[str],
95
+ extra_environment: dict[str, str] | None = None,
96
+ ) -> tuple[int, str]:
97
+ raise AssertionError("gh must not run once copilot is opted out")
98
+
99
+ monkeypatch.setattr(copilot_quota, "_run_gh", _fail_if_called)
100
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
101
+ captured = capsys.readouterr()
102
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
103
+ assert "CLAUDE_REVIEWS_DISABLED" in captured.err
104
+
105
+
106
+ def test_main_reports_copilot_down_when_out_of_quota(
107
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
108
+ ) -> None:
109
+ monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
110
+ monkeypatch.setattr(
111
+ copilot_quota,
112
+ "_run_gh",
113
+ _gh_stub(FAKE_TOKEN_RESULT, (0, _exhausted_user_json())),
114
+ )
115
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
116
+ captured = capsys.readouterr()
117
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
118
+ assert "scenario A" in captured.err
119
+
120
+
121
+ def test_main_reports_copilot_down_when_quota_api_is_down(
122
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
123
+ ) -> None:
124
+ monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
125
+ monkeypatch.setattr(
126
+ copilot_quota, "_run_gh", _gh_stub(FAKE_TOKEN_RESULT, (0, "not json at all"))
127
+ )
128
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
129
+ captured = capsys.readouterr()
130
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
131
+ assert "scenario B" in captured.err
132
+
133
+
134
+ def test_main_reports_copilot_down_when_no_account_configured(
135
+ capsys: pytest.CaptureFixture[str],
136
+ ) -> None:
137
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
138
+ captured = capsys.readouterr()
139
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
140
+ assert "scenario C" in captured.err
141
+
142
+
143
+ def test_main_reports_bugbot_available_when_not_opted_out(
144
+ capsys: pytest.CaptureFixture[str],
145
+ ) -> None:
146
+ exit_code = reviewer_availability.main(["--reviewer", "bugbot"])
147
+ captured = capsys.readouterr()
148
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_AVAILABLE
149
+ assert "bugbot" in captured.out
150
+
151
+
152
+ def test_main_reports_bugbot_down_when_opted_out_via_env(
153
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
154
+ ) -> None:
155
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
156
+ exit_code = reviewer_availability.main(["--reviewer", "bugbot"])
157
+ captured = capsys.readouterr()
158
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
159
+ assert "CLAUDE_REVIEWS_DISABLED" in captured.err
@@ -0,0 +1,36 @@
1
+ """Tests for reviewer_availability_constants.py extracted constant set."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = (
10
+ Path(__file__).parent.parent
11
+ / "pr_loop_shared_constants"
12
+ / "reviewer_availability_constants.py"
13
+ )
14
+ specification = importlib.util.spec_from_file_location(
15
+ "pr_loop_shared_constants.reviewer_availability_constants", module_path
16
+ )
17
+ assert specification is not None
18
+ assert specification.loader is not None
19
+ module = importlib.util.module_from_spec(specification)
20
+ specification.loader.exec_module(module)
21
+ return module
22
+
23
+
24
+ constants_module = _load_constants_module()
25
+
26
+
27
+ def test_available_exit_code_matches_the_posix_success_convention() -> None:
28
+ assert constants_module.EXIT_CODE_REVIEWER_AVAILABLE == 0
29
+
30
+
31
+ def test_down_exit_code_is_distinct_from_the_available_exit_code() -> None:
32
+ assert (
33
+ constants_module.EXIT_CODE_REVIEWER_DOWN
34
+ != constants_module.EXIT_CODE_REVIEWER_AVAILABLE
35
+ )
36
+ assert constants_module.EXIT_CODE_REVIEWER_DOWN != 0
@@ -191,7 +191,7 @@ def test_does_not_flag_y_to_ies_plural_variant() -> None:
191
191
  assert sweep_diff(diff) == []
192
192
 
193
193
 
194
- def test_still_flags_non_plural_divergent_tail() -> None:
194
+ def test_does_not_flag_spaced_prose_sharing_identifier_prefix() -> None:
195
195
  diff = (
196
196
  "diff --git a/api/quota.py b/api/quota.py\n"
197
197
  "--- a/api/quota.py\n"
@@ -204,9 +204,19 @@ def test_still_flags_non_plural_divergent_tail() -> None:
204
204
  "@@ -0,0 +1,1 @@\n"
205
205
  "+The premium request budget gates the run.\n"
206
206
  )
207
- findings = sweep_diff(diff)
208
- assert len(findings) == 1
209
- assert "premium request" in findings[0]
207
+ assert sweep_diff(diff) == []
208
+
209
+
210
+ def test_does_not_flag_ordinary_sentence_sharing_loop_variable_prefix() -> None:
211
+ diff = (
212
+ "diff --git a/api/scan.py b/api/scan.py\n"
213
+ "--- a/api/scan.py\n"
214
+ "+++ b/api/scan.py\n"
215
+ "@@ -0,0 +1,2 @@\n"
216
+ "+each_node = walk(tree)\n"
217
+ '+DESCRIPTION = "Each attempt polls the review endpoint once."\n'
218
+ )
219
+ assert sweep_diff(diff) == []
210
220
 
211
221
 
212
222
  def test_flags_near_miss_inside_code_comment() -> None:
@@ -86,9 +86,11 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
86
86
  | `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
87
87
  | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
88
88
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
89
+ | `reviewer_spawn_gate.py` | PreToolUse (Bash) | A sentinel-marked autoconverge reviewer-spawn command (Copilot review request, Bugbot rerun comment) run while `reviewer_availability.py` reports that reviewer down or out of quota |
89
90
  | `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file locally via `Show-Asset.ps1` |
90
91
  | `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
91
92
  | `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
93
+ | `stale_comment_reference_blocker.py` | PreToolUse (Edit) | An Edit that rewrites a Python code line while keeping the standalone comment directly above it, when that comment names an identifier the rewrite removes from the line |
92
94
  | `state_description_blocker.py` | PreToolUse (Write/Edit) | Historical/comparative language in documentation |
93
95
  | `subprocess_budget_completeness.py` | PreToolUse | Subprocess calls missing required budget arguments |
94
96
  | `tdd_enforcer.py` | PreToolUse (Write/Edit) | Production code written without a matching failing test |