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.
- package/_shared/pr-loop/scripts/CLAUDE.md +3 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +58 -4
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +7 -66
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +10 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +28 -7
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +235 -247
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- 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
|
+
}
|