claude-dev-env 1.79.0 → 1.81.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 (76) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +3 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
  10. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  12. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  13. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  14. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  15. package/audit-rubrics/CLAUDE.md +3 -2
  16. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  17. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  18. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  19. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  20. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  21. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  22. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  23. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  24. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  25. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  26. package/docs/CODE_RULES.md +2 -2
  27. package/hooks/blocking/CLAUDE.md +2 -0
  28. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  29. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  30. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  31. package/hooks/blocking/code_rules_docstrings.py +93 -0
  32. package/hooks/blocking/code_rules_enforcer.py +58 -4
  33. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  34. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  35. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  36. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  37. package/hooks/blocking/code_rules_unused_imports.py +7 -66
  38. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  40. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  41. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  42. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  43. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  45. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  46. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
  47. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  48. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  49. package/hooks/hooks_constants/CLAUDE.md +2 -0
  50. package/hooks/hooks_constants/blocking_check_limits.py +10 -0
  51. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  52. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  53. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  54. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  55. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  56. package/package.json +1 -1
  57. package/rules/docstring-prose-matches-implementation.md +3 -1
  58. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  59. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  60. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  61. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  62. package/skills/autoconverge/SKILL.md +28 -7
  63. package/skills/autoconverge/reference/convergence.md +3 -3
  64. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  65. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  66. package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
  67. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
  68. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  69. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  70. package/skills/autoconverge/workflow/converge.mjs +235 -247
  71. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  72. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  74. package/skills/pr-converge/SKILL.md +28 -2
  75. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  76. package/skills/pr-converge/reference/state-schema.md +11 -0
@@ -0,0 +1,113 @@
1
+ """Constants for the commit-time terminology sweep."""
2
+
3
+ import re
4
+
5
+ ALL_SWEEP_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
6
+ {".py", ".mjs", ".js", ".ts"}
7
+ )
8
+
9
+ MARKDOWN_FILE_EXTENSION: str = ".md"
10
+
11
+ SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
12
+ r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
13
+ )
14
+
15
+ CAMEL_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
16
+ r"\b[a-z]+(?:[A-Z][a-z0-9]*)+\b"
17
+ )
18
+
19
+ CAMEL_CASE_WORD_PATTERN: re.Pattern[str] = re.compile(
20
+ r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+"
21
+ )
22
+
23
+ HYPHENATED_PROSE_TOKEN_PATTERN: re.Pattern[str] = re.compile(
24
+ r"\b[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+\b"
25
+ )
26
+
27
+ PROSE_WORD_PATTERN: re.Pattern[str] = re.compile(r"[A-Za-z][A-Za-z0-9]*")
28
+
29
+ STRING_LITERAL_CONTENT_PATTERN: re.Pattern[str] = re.compile(
30
+ r"\"([^\"]*)\"|'([^']*)'|`([^`]*)`"
31
+ )
32
+
33
+ DIFF_FILE_HEADER_PREFIX: str = "+++ "
34
+
35
+ DIFF_HUNK_HEADER_PATTERN: re.Pattern[str] = re.compile(
36
+ r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@"
37
+ )
38
+
39
+ DIFF_ADDED_LINE_PREFIX: str = "+"
40
+
41
+ DIFF_REMOVED_LINE_PREFIX: str = "-"
42
+
43
+ DIFF_NEW_FILE_HEADER_PREFIX: str = "+++"
44
+
45
+ DIFF_OLD_FILE_HEADER_PREFIX: str = "---"
46
+
47
+ ALL_DIFF_FILE_PATH_STRIP_PREFIXES: tuple[str, ...] = ("a/", "b/")
48
+
49
+ PYTHON_COMMENT_MARKER: str = "#"
50
+
51
+ JAVASCRIPT_LINE_COMMENT_MARKER: str = "//"
52
+
53
+ JSDOC_CONTINUATION_MARKER: str = "*"
54
+
55
+ MINIMUM_IDENTIFIER_TOKEN_COUNT: int = 2
56
+
57
+ ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS: frozenset[str] = frozenset(
58
+ {
59
+ "only",
60
+ "driven",
61
+ "safe",
62
+ "based",
63
+ "level",
64
+ "quality",
65
+ "known",
66
+ "source",
67
+ "party",
68
+ "case",
69
+ "time",
70
+ "wide",
71
+ "free",
72
+ "friendly",
73
+ "specific",
74
+ "aware",
75
+ "ready",
76
+ "side",
77
+ "grade",
78
+ "oriented",
79
+ "related",
80
+ "sensitive",
81
+ "facing",
82
+ }
83
+ )
84
+
85
+ TERMINOLOGY_FINDING_TEMPLATE: str = (
86
+ "{file_path}:{line_number}: prose term '{candidate}' near-misses code "
87
+ "identifier '{identifier}' (shared prefix token, divergent tail) — align "
88
+ "the prose wording with the identifier"
89
+ )
90
+
91
+ TERMINOLOGY_SWEEP_DESCRIPTION: str = (
92
+ "Flag prose terms that near-miss an identifier introduced on added code "
93
+ "lines of a unified diff."
94
+ )
95
+
96
+ DIFF_FILE_ARGUMENT_HELP: str = (
97
+ "Read the unified diff from this file instead of standard input."
98
+ )
99
+
100
+
101
+ ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND: tuple[str, ...] = (
102
+ "git",
103
+ "diff",
104
+ "--cached",
105
+ "--unified=0",
106
+ )
107
+
108
+ GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS: int = 30
109
+
110
+ TERMINOLOGY_SWEEP_GATE_HEADER: str = (
111
+ "terminology_sweep: {finding_count} cross-surface terminology near-miss(es) "
112
+ "on staged prose:"
113
+ )
@@ -0,0 +1,467 @@
1
+ """Flag prose terms that near-miss an identifier a diff introduces.
2
+
3
+ Picture a change that adds an API field named ``premium_interactions``. On the
4
+ same branch, the docs call it the ``premium-request`` budget. The code and the
5
+ prose now name one thing two ways. A reader who searches for one term never
6
+ finds the other.
7
+
8
+ This sweep reads a unified diff and collects every multi-word identifier added
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
12
+ near-miss prints as one ``file:line`` finding, and the run exits non-zero when
13
+ any finding remains, so a commit gate can block on it.
14
+
15
+ A shared leading token alone is too weak a signal: ordinary English compounds
16
+ such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
17
+ (``read_config``, ``data_source``) and would block a commit falsely. To keep the
18
+ grounding case (``premium-request`` against ``premium_interactions``) firing
19
+ while sparing those compounds, a candidate whose final token is a common English
20
+ compound tail word (``only``, ``driven``, ``safe``, and the rest listed in
21
+ ``ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS``) is treated as ordinary prose, not a
22
+ near-miss.
23
+
24
+ Ordinary singular/plural prose is spared the same way. A candidate that differs
25
+ from an identifier only by a singular/plural form of one or more tokens
26
+ (``test files`` against ``test_file``, ``retry policies`` against
27
+ ``retry_policy``) is treated as the same term, not a near-miss.
28
+ """
29
+
30
+ import argparse
31
+ import subprocess
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ parent_directory = str(Path(__file__).resolve().parent)
36
+ if parent_directory not in sys.path:
37
+ sys.path.insert(0, parent_directory)
38
+
39
+ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
40
+ ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS,
41
+ ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
42
+ ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
43
+ ALL_SWEEP_CODE_FILE_EXTENSIONS,
44
+ CAMEL_CASE_IDENTIFIER_PATTERN,
45
+ CAMEL_CASE_WORD_PATTERN,
46
+ DIFF_ADDED_LINE_PREFIX,
47
+ DIFF_FILE_ARGUMENT_HELP,
48
+ DIFF_FILE_HEADER_PREFIX,
49
+ DIFF_HUNK_HEADER_PATTERN,
50
+ DIFF_NEW_FILE_HEADER_PREFIX,
51
+ DIFF_OLD_FILE_HEADER_PREFIX,
52
+ DIFF_REMOVED_LINE_PREFIX,
53
+ GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
54
+ HYPHENATED_PROSE_TOKEN_PATTERN,
55
+ JAVASCRIPT_LINE_COMMENT_MARKER,
56
+ JSDOC_CONTINUATION_MARKER,
57
+ MARKDOWN_FILE_EXTENSION,
58
+ MINIMUM_IDENTIFIER_TOKEN_COUNT,
59
+ PROSE_WORD_PATTERN,
60
+ PYTHON_COMMENT_MARKER,
61
+ SNAKE_CASE_IDENTIFIER_PATTERN,
62
+ STRING_LITERAL_CONTENT_PATTERN,
63
+ TERMINOLOGY_FINDING_TEMPLATE,
64
+ TERMINOLOGY_SWEEP_DESCRIPTION,
65
+ )
66
+
67
+ IdentifierTuple = tuple[str, ...]
68
+
69
+
70
+ def _strip_diff_path_prefix(raw_path: str) -> str:
71
+ """Return the diff path with a leading ``a/`` or ``b/`` marker removed."""
72
+ stripped_path = raw_path.strip()
73
+ for each_prefix in ALL_DIFF_FILE_PATH_STRIP_PREFIXES:
74
+ if stripped_path.startswith(each_prefix):
75
+ return stripped_path[len(each_prefix) :]
76
+ return stripped_path
77
+
78
+
79
+ def _parse_added_lines(diff_text: str) -> list[tuple[str, int, str]]:
80
+ """Return each added line of a unified diff as ``(path, line_number, text)``.
81
+
82
+ Args:
83
+ diff_text: The unified-diff text to scan.
84
+
85
+ Returns:
86
+ One ``(file_path, line_number, added_text)`` triple per added line, with
87
+ the 1-indexed line number the added line occupies in the new file.
88
+ """
89
+ all_added_lines: list[tuple[str, int, str]] = []
90
+ current_file_path = ""
91
+ next_line_number = 0
92
+ for each_line in diff_text.split("\n"):
93
+ if each_line.startswith(DIFF_FILE_HEADER_PREFIX):
94
+ current_file_path = _strip_diff_path_prefix(
95
+ each_line[len(DIFF_FILE_HEADER_PREFIX) :]
96
+ )
97
+ continue
98
+ hunk_match = DIFF_HUNK_HEADER_PATTERN.match(each_line)
99
+ if hunk_match is not None:
100
+ next_line_number = int(hunk_match.group(1))
101
+ continue
102
+ if each_line.startswith(DIFF_ADDED_LINE_PREFIX):
103
+ all_added_lines.append((current_file_path, next_line_number, each_line[1:]))
104
+ next_line_number += 1
105
+ continue
106
+ if each_line.startswith(
107
+ (DIFF_NEW_FILE_HEADER_PREFIX, DIFF_OLD_FILE_HEADER_PREFIX)
108
+ ):
109
+ continue
110
+ if each_line.startswith(DIFF_REMOVED_LINE_PREFIX):
111
+ continue
112
+ next_line_number += 1
113
+ return all_added_lines
114
+
115
+
116
+ def _file_extension(file_path: str) -> str:
117
+ """Return the lowercase extension of a diff path, or the empty string."""
118
+ return Path(file_path).suffix.lower()
119
+
120
+
121
+ def _identifier_token_tuple(identifier: str) -> IdentifierTuple:
122
+ """Return the lowercase word tokens of a snake_case or camelCase identifier."""
123
+ if "_" in identifier:
124
+ return tuple(
125
+ each_segment.lower()
126
+ for each_segment in identifier.split("_")
127
+ if each_segment
128
+ )
129
+ return tuple(
130
+ each_piece.lower() for each_piece in CAMEL_CASE_WORD_PATTERN.findall(identifier)
131
+ )
132
+
133
+
134
+ def _identifier_tuples_in_text(code_text: str) -> list[IdentifierTuple]:
135
+ """Return every multi-word identifier tuple appearing in one code line."""
136
+ all_identifier_names = SNAKE_CASE_IDENTIFIER_PATTERN.findall(code_text)
137
+ all_identifier_names += CAMEL_CASE_IDENTIFIER_PATTERN.findall(code_text)
138
+ all_tuples: list[IdentifierTuple] = []
139
+ for each_name in all_identifier_names:
140
+ token_tuple = _identifier_token_tuple(each_name)
141
+ if len(token_tuple) >= MINIMUM_IDENTIFIER_TOKEN_COUNT:
142
+ all_tuples.append(token_tuple)
143
+ return all_tuples
144
+
145
+
146
+ def _collect_introduced_identifiers(
147
+ all_added_lines: list[tuple[str, int, str]],
148
+ ) -> tuple[frozenset[IdentifierTuple], dict[str, list[IdentifierTuple]]]:
149
+ """Return the identifier tuples introduced on added code lines.
150
+
151
+ Args:
152
+ all_added_lines: Added-line triples from :func:`_parse_added_lines`.
153
+
154
+ Returns:
155
+ A ``(all_identifier_tuples, identifiers_by_first_token)`` pair. The map
156
+ groups the tuples by their leading token so the near-miss check reaches
157
+ every candidate for a given prefix without rescanning the whole set.
158
+ """
159
+ all_identifier_tuples: set[IdentifierTuple] = set()
160
+ for each_file_path, _, each_text in all_added_lines:
161
+ if _file_extension(each_file_path) in ALL_SWEEP_CODE_FILE_EXTENSIONS:
162
+ all_identifier_tuples.update(_identifier_tuples_in_text(each_text))
163
+ identifiers_by_first_token: dict[str, list[IdentifierTuple]] = {}
164
+ for each_tuple in all_identifier_tuples:
165
+ identifiers_by_first_token.setdefault(each_tuple[0], []).append(each_tuple)
166
+ return frozenset(all_identifier_tuples), identifiers_by_first_token
167
+
168
+
169
+ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
170
+ """Return the prose fragments of an added line worth scanning for terms.
171
+
172
+ A Markdown line is prose in full. A code line contributes its comment tail,
173
+ its JSDoc continuation text, and the contents of its string literals.
174
+
175
+ Args:
176
+ file_path: The path the added line belongs to.
177
+ line_text: The added line's text without its diff marker.
178
+
179
+ Returns:
180
+ The prose fragments to scan for near-miss terms.
181
+ """
182
+ if _file_extension(file_path) == MARKDOWN_FILE_EXTENSION:
183
+ return [line_text]
184
+ if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
185
+ return []
186
+ all_fragments: list[str] = []
187
+ stripped_line = line_text.strip()
188
+ if stripped_line.startswith(JSDOC_CONTINUATION_MARKER):
189
+ all_fragments.append(stripped_line)
190
+ all_fragments.extend(_comment_fragments(line_text))
191
+ all_fragments.extend(_string_literal_fragments(line_text))
192
+ return all_fragments
193
+
194
+
195
+ def _comment_fragments(line_text: str) -> list[str]:
196
+ """Return the comment tail of a code line, or an empty list when absent."""
197
+ all_fragments: list[str] = []
198
+ python_marker_index = line_text.find(PYTHON_COMMENT_MARKER)
199
+ if python_marker_index != -1:
200
+ all_fragments.append(
201
+ line_text[python_marker_index + len(PYTHON_COMMENT_MARKER) :]
202
+ )
203
+ javascript_marker_index = line_text.find(JAVASCRIPT_LINE_COMMENT_MARKER)
204
+ if javascript_marker_index != -1:
205
+ all_fragments.append(
206
+ line_text[javascript_marker_index + len(JAVASCRIPT_LINE_COMMENT_MARKER) :]
207
+ )
208
+ return all_fragments
209
+
210
+
211
+ def _string_literal_fragments(line_text: str) -> list[str]:
212
+ """Return the contents of every quoted string literal on a code line."""
213
+ all_fragments: list[str] = []
214
+ for each_match in STRING_LITERAL_CONTENT_PATTERN.finditer(line_text):
215
+ each_content = next(
216
+ (
217
+ each_group
218
+ for each_group in each_match.groups()
219
+ if each_group is not None
220
+ ),
221
+ "",
222
+ )
223
+ all_fragments.append(each_content)
224
+ return all_fragments
225
+
226
+
227
+ def _hyphenated_candidates(fragment: str) -> list[tuple[str, IdentifierTuple]]:
228
+ """Return ``(display, token_tuple)`` for every hyphenated word in a fragment."""
229
+ all_candidates: list[tuple[str, IdentifierTuple]] = []
230
+ for each_match in HYPHENATED_PROSE_TOKEN_PATTERN.finditer(fragment):
231
+ display = each_match.group(0)
232
+ token_tuple = tuple(each_word.lower() for each_word in display.split("-"))
233
+ all_candidates.append((display, token_tuple))
234
+ return all_candidates
235
+
236
+
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
+ def _tokens_are_plural_variants(first_word: str, second_word: str) -> bool:
267
+ """Return whether two words are singular/plural forms of one word.
268
+
269
+ Covers the regular English plural rules: an appended ``s`` (``file`` /
270
+ ``files``), an appended ``es`` (``box`` / ``boxes``), and the ``y`` to
271
+ ``ies`` swap (``policy`` / ``policies``). The check is symmetric, so it
272
+ holds whichever word is the singular form.
273
+ """
274
+ if first_word == second_word:
275
+ return True
276
+ shorter_word, longer_word = sorted((first_word, second_word), key=len)
277
+ if longer_word in (shorter_word + "s", shorter_word + "es"):
278
+ return True
279
+ return shorter_word.endswith("y") and longer_word == shorter_word[:-1] + "ies"
280
+
281
+
282
+ def _tuples_match_ignoring_plural(
283
+ first_tuple: IdentifierTuple, second_tuple: IdentifierTuple
284
+ ) -> bool:
285
+ """Return whether two equal-length tuples match token-for-token by plural."""
286
+ return all(
287
+ _tokens_are_plural_variants(each_first_word, each_second_word)
288
+ for each_first_word, each_second_word in zip(first_tuple, second_tuple)
289
+ )
290
+
291
+
292
+ def _near_miss_identifier(
293
+ candidate_tuple: IdentifierTuple,
294
+ all_identifier_tuples: frozenset[IdentifierTuple],
295
+ identifiers_by_first_token: dict[str, list[IdentifierTuple]],
296
+ ) -> IdentifierTuple | None:
297
+ """Return an identifier the candidate near-misses, or None when it does not.
298
+
299
+ Args:
300
+ candidate_tuple: The prose term's lowercase token tuple.
301
+ all_identifier_tuples: Every introduced identifier tuple.
302
+ identifiers_by_first_token: Identifier tuples grouped by leading token.
303
+
304
+ Returns:
305
+ The first identifier sharing the candidate's leading token and length but
306
+ differing in a later token, or None when the candidate matches an
307
+ identifier exactly, shares no prefix, ends in a common English compound
308
+ tail word (``read-only``, ``data-driven``), or differs from the
309
+ identifier only by a singular/plural form of one or more tokens
310
+ (``test files`` against ``test_file``) — each of which marks the
311
+ candidate ordinary prose rather than a near-miss of the identifier.
312
+ """
313
+ if not candidate_tuple or candidate_tuple in all_identifier_tuples:
314
+ return None
315
+ if candidate_tuple[-1] in ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS:
316
+ return None
317
+ for each_identifier in identifiers_by_first_token.get(candidate_tuple[0], []):
318
+ if len(each_identifier) != len(candidate_tuple):
319
+ continue
320
+ if each_identifier == candidate_tuple:
321
+ continue
322
+ if _tuples_match_ignoring_plural(each_identifier, candidate_tuple):
323
+ continue
324
+ return each_identifier
325
+ return None
326
+
327
+
328
+ def _findings_for_line(
329
+ file_path: str,
330
+ line_number: int,
331
+ line_text: str,
332
+ all_identifier_tuples: frozenset[IdentifierTuple],
333
+ identifiers_by_first_token: dict[str, list[IdentifierTuple]],
334
+ ) -> list[str]:
335
+ """Return the near-miss findings for one added line.
336
+
337
+ Args:
338
+ file_path: The path the added line belongs to.
339
+ line_number: The added line's 1-indexed number in the new file.
340
+ line_text: The added line's text without its diff marker.
341
+ all_identifier_tuples: Every introduced identifier tuple.
342
+ identifiers_by_first_token: Identifier tuples grouped by leading token.
343
+
344
+ Returns:
345
+ One finding string per distinct near-miss term on the line.
346
+ """
347
+ all_findings: list[str] = []
348
+ reported_tuples: set[IdentifierTuple] = set()
349
+ for each_fragment in _prose_fragments(file_path, line_text):
350
+ all_candidates = _hyphenated_candidates(each_fragment)
351
+ all_candidates += _spaced_candidates(each_fragment, identifiers_by_first_token)
352
+ for each_display, each_tuple in all_candidates:
353
+ matched_identifier = _near_miss_identifier(
354
+ each_tuple, all_identifier_tuples, identifiers_by_first_token
355
+ )
356
+ if matched_identifier is None or each_tuple in reported_tuples:
357
+ continue
358
+ reported_tuples.add(each_tuple)
359
+ all_findings.append(
360
+ TERMINOLOGY_FINDING_TEMPLATE.format(
361
+ file_path=file_path,
362
+ line_number=line_number,
363
+ candidate=each_display,
364
+ identifier="_".join(matched_identifier),
365
+ )
366
+ )
367
+ return all_findings
368
+
369
+
370
+ def sweep_diff(diff_text: str) -> list[str]:
371
+ """Return every terminology near-miss finding for a unified diff.
372
+
373
+ Args:
374
+ diff_text: The unified-diff text to sweep.
375
+
376
+ Returns:
377
+ One finding string per near-miss term on an added prose line.
378
+ """
379
+ all_added_lines = _parse_added_lines(diff_text)
380
+ all_identifier_tuples, identifiers_by_first_token = _collect_introduced_identifiers(
381
+ all_added_lines
382
+ )
383
+ if not all_identifier_tuples:
384
+ return []
385
+ all_findings: list[str] = []
386
+ for each_file_path, each_line_number, each_text in all_added_lines:
387
+ all_findings.extend(
388
+ _findings_for_line(
389
+ each_file_path,
390
+ each_line_number,
391
+ each_text,
392
+ all_identifier_tuples,
393
+ identifiers_by_first_token,
394
+ )
395
+ )
396
+ return all_findings
397
+
398
+
399
+ def staged_terminology_findings(repository_root: Path) -> list[str]:
400
+ """Return terminology near-miss findings for a repository's staged diff.
401
+
402
+ Args:
403
+ repository_root: The repository root the staged diff is read from.
404
+
405
+ Returns:
406
+ One finding string per near-miss term on a staged added prose line, or
407
+ an empty list when the staged diff is empty or git cannot be run.
408
+ """
409
+ diff_process = subprocess.run(
410
+ list(ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND),
411
+ cwd=str(repository_root),
412
+ capture_output=True,
413
+ text=True,
414
+ encoding="utf-8",
415
+ errors="replace",
416
+ timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
417
+ check=False,
418
+ )
419
+ if diff_process.returncode != 0:
420
+ return []
421
+ return sweep_diff(diff_process.stdout)
422
+
423
+
424
+ def _read_diff_text(diff_file_path: str | None) -> str:
425
+ """Return the diff text from a file path, or from standard input when None."""
426
+ if diff_file_path is None:
427
+ return sys.stdin.read()
428
+ return Path(diff_file_path).read_text(encoding="utf-8")
429
+
430
+
431
+ def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
432
+ """Return the parsed command-line arguments for the terminology sweep.
433
+
434
+ Args:
435
+ all_arguments: The argument vector following the script name.
436
+
437
+ Returns:
438
+ The parsed namespace carrying the optional ``diff_file`` path.
439
+ """
440
+ parser = argparse.ArgumentParser(description=TERMINOLOGY_SWEEP_DESCRIPTION)
441
+ parser.add_argument(
442
+ "--diff-file",
443
+ dest="diff_file",
444
+ default=None,
445
+ help=DIFF_FILE_ARGUMENT_HELP,
446
+ )
447
+ return parser.parse_args(all_arguments)
448
+
449
+
450
+ def main(all_arguments: list[str]) -> int:
451
+ """Run the sweep and return the process exit code.
452
+
453
+ Args:
454
+ all_arguments: The argument vector following the script name.
455
+
456
+ Returns:
457
+ The value 1 when any near-miss finding exists, and 0 when none remain.
458
+ """
459
+ arguments = _parse_arguments(all_arguments)
460
+ all_findings = sweep_diff(_read_diff_text(arguments.diff_file))
461
+ for each_finding in all_findings:
462
+ print(each_finding)
463
+ return 1 if all_findings else 0
464
+
465
+
466
+ if __name__ == "__main__":
467
+ raise SystemExit(main(sys.argv[1:]))
@@ -11,6 +11,7 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
11
11
  | `test_claude_permissions_constants.py` | `pr_loop_shared_constants/claude_permissions_constants.py` |
12
12
  | `test_claude_settings_keys_constants.py` | `pr_loop_shared_constants/claude_settings_keys_constants.py` |
13
13
  | `test_code_rules_gate.py` | `code_rules_gate.py` gate logic |
14
+ | `test_terminology_sweep.py` | `terminology_sweep.py` near-miss detection and exit codes |
14
15
  | `test_code_rules_gate_constants.py` | `pr_loop_shared_constants/code_rules_gate_constants.py` |
15
16
  | `test_fix_hookspath.py` | `fix_hookspath.py` repair logic |
16
17
  | `test_fix_hookspath_constants.py` | `pr_loop_shared_constants/fix_hookspath_constants.py` |
@@ -21,10 +22,17 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
21
22
  | `test_preflight_constants.py` | `pr_loop_shared_constants/preflight_constants.py` |
22
23
  | `test_preflight_self_heal.py` | `preflight_self_heal.py` hooks-path repair |
23
24
  | `test_reviews_disabled.py` | `reviews_disabled.py` opt-out gate parsing |
25
+ | `test_copilot_quota.py` | `copilot_quota.py` end-to-end: account resolution, premium-quota classification, exit codes, and skip logging |
26
+ | `test_copilot_quota_constants.py` | `pr_loop_shared_constants/copilot_quota_constants.py` |
24
27
  | `test_revoke_project_claude_permissions.py` | `revoke_project_claude_permissions.py` end-to-end |
25
28
  | `test_agent_config_carveout.py` | Agent-config deny-rule carve-out logic |
26
29
  | `conftest.py` | Shared pytest fixtures |
27
30
 
31
+ ## Fixtures
32
+
33
+ `fixtures/copilot_internal_user_jonecho.json` — a captured `gh api
34
+ copilot_internal/user` response driving `test_copilot_quota.py`.
35
+
28
36
  ## Running
29
37
 
30
38
  ```bash
@@ -0,0 +1,76 @@
1
+ {
2
+ "login": "jonecho",
3
+ "access_type_sku": "plus_monthly_subscriber_quota",
4
+ "assigned_date": "2026-05-09T14:08:29-04:00",
5
+ "can_signup_for_limited": false,
6
+ "chat_enabled": true,
7
+ "cli_enabled": true,
8
+ "copilotignore_enabled": false,
9
+ "copilot_plan": "individual_pro",
10
+ "editor_preview_features_enabled": true,
11
+ "is_mcp_enabled": true,
12
+ "is_staff": false,
13
+ "organization_login_list": [],
14
+ "organization_list": [],
15
+ "restricted_telemetry": false,
16
+ "cli_remote_control_enabled": true,
17
+ "cloud_session_storage_enabled": true,
18
+ "endpoints": {
19
+ "api": "https://api.individual.githubcopilot.com",
20
+ "origin-tracker": "https://origin-tracker.individual.githubcopilot.com",
21
+ "proxy": "https://proxy.individual.githubcopilot.com",
22
+ "telemetry": "https://telemetry.individual.githubcopilot.com"
23
+ },
24
+ "can_upgrade_plan": true,
25
+ "codex_agent_enabled": true,
26
+ "quota_reset_date": "2026-08-01",
27
+ "quota_snapshots": {
28
+ "chat": {
29
+ "overage_count": 0,
30
+ "overage_entitlement": 0,
31
+ "overage_permitted": false,
32
+ "percent_remaining": 100.0,
33
+ "quota_id": "chat",
34
+ "quota_remaining": 0.0,
35
+ "unlimited": true,
36
+ "timestamp_utc": "2026-07-01T11:05:17.669Z",
37
+ "has_quota": true,
38
+ "quota_reset_at": 0,
39
+ "token_based_billing": true,
40
+ "remaining": 0,
41
+ "entitlement": 0
42
+ },
43
+ "completions": {
44
+ "overage_count": 0,
45
+ "overage_entitlement": 0,
46
+ "overage_permitted": false,
47
+ "percent_remaining": 100.0,
48
+ "quota_id": "completions",
49
+ "quota_remaining": 0.0,
50
+ "unlimited": true,
51
+ "timestamp_utc": "2026-07-01T11:05:17.669Z",
52
+ "has_quota": true,
53
+ "quota_reset_at": 0,
54
+ "token_based_billing": true,
55
+ "remaining": 0,
56
+ "entitlement": 0
57
+ },
58
+ "premium_interactions": {
59
+ "overage_count": 0,
60
+ "overage_entitlement": 0,
61
+ "overage_permitted": false,
62
+ "percent_remaining": 97.3,
63
+ "quota_id": "premium_interactions",
64
+ "quota_remaining": 6817.0,
65
+ "unlimited": false,
66
+ "timestamp_utc": "2026-07-01T11:05:17.669Z",
67
+ "has_quota": true,
68
+ "quota_reset_at": 0,
69
+ "token_based_billing": true,
70
+ "remaining": 6817,
71
+ "entitlement": 7000
72
+ }
73
+ },
74
+ "quota_reset_date_utc": "2026-08-01T00:00:00.000Z",
75
+ "token_based_billing": true
76
+ }