claude-dev-env 1.85.0 → 1.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +142 -7
- package/_shared/pr-loop/scripts/terminology_sweep.py +288 -92
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +180 -91
- package/agents/code-quality-agent.md +1 -1
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/re-stage-before-commit.md +31 -0
- package/skills/autoconverge/SKILL.md +4 -4
- package/skills/autoconverge/reference/gotchas.md +3 -2
- package/skills/autoconverge/reference/stop-conditions.md +22 -6
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +528 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +29 -21
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +77 -8
- package/skills/autoconverge/workflow/converge.mjs +456 -59
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
"""Flag a prose term that
|
|
1
|
+
"""Flag a prose term that names a code identifier a hair differently.
|
|
2
2
|
|
|
3
3
|
A change adds the field ``premium_interactions`` while the same branch's docs
|
|
4
|
-
call it the ``premium-request`` budget:
|
|
5
|
-
|
|
4
|
+
call it the ``premium-request`` budget: one thing, two names, and a reader who
|
|
5
|
+
searches one never finds the other.
|
|
6
6
|
|
|
7
7
|
::
|
|
8
8
|
|
|
9
|
-
code adds: premium_interactions
|
|
10
|
-
prose adds:
|
|
11
|
-
flag: premium-request vs premium_interactions
|
|
12
|
-
ok: premium-interactions
|
|
13
|
-
ok: read-only, test files
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
blocks on it.
|
|
9
|
+
code adds: premium_interactions the identifier
|
|
10
|
+
prose adds: the premium-request budget a hyphenated term
|
|
11
|
+
flag: premium-request vs premium_interactions -- shared first word, tail diverges
|
|
12
|
+
ok: premium-interactions -- exact hyphen form, agrees
|
|
13
|
+
ok: read-only, test files, to a file -- English compound, plural, or stopword
|
|
14
|
+
|
|
15
|
+
The sweep reads a unified diff, collects the multi-word identifiers added on
|
|
16
|
+
code lines, and scans each added prose line for a hyphen or space variant that
|
|
17
|
+
shares an identifier's leading word but diverges after it. It spares ordinary
|
|
18
|
+
prose: English compound tails, singular/plural forms, stopword windows, and
|
|
19
|
+
words that are themselves tokens of another introduced identifier.
|
|
21
20
|
"""
|
|
22
21
|
|
|
23
22
|
import argparse
|
|
@@ -32,10 +31,19 @@ if parent_directory not in sys.path:
|
|
|
32
31
|
|
|
33
32
|
from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
34
33
|
ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS,
|
|
34
|
+
ALL_GIT_GREP_BASE_TREE_COMMAND_PREFIX,
|
|
35
|
+
ALL_PROSE_STOPWORD_TOKENS,
|
|
36
|
+
ALL_STRING_ESCAPE_SEQUENCES,
|
|
37
|
+
ALL_TEST_FILE_NAME_INFIX_MARKERS,
|
|
38
|
+
GIT_BASE_TREE_REVISION,
|
|
39
|
+
IDENTIFIER_TOKEN_SEPARATOR,
|
|
40
|
+
PROSE_WINDOW_WORD_SEPARATOR,
|
|
41
|
+
TEST_DIRECTORY_PATH_SEGMENT,
|
|
42
|
+
TEST_FILE_PREFIX,
|
|
43
|
+
TEST_FILE_SUFFIX,
|
|
35
44
|
ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
|
|
36
45
|
ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
|
|
37
46
|
ALL_SWEEP_CODE_FILE_EXTENSIONS,
|
|
38
|
-
ALL_TEST_FILE_NAME_INFIX_MARKERS,
|
|
39
47
|
CAMEL_CASE_IDENTIFIER_PATTERN,
|
|
40
48
|
CAMEL_CASE_WORD_PATTERN,
|
|
41
49
|
DIFF_ADDED_LINE_PREFIX,
|
|
@@ -47,18 +55,17 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
|
47
55
|
DIFF_REMOVED_LINE_PREFIX,
|
|
48
56
|
GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
49
57
|
HYPHENATED_PROSE_TOKEN_PATTERN,
|
|
58
|
+
INLINE_CODE_SPAN_PATTERN,
|
|
50
59
|
JAVASCRIPT_LINE_COMMENT_MARKER,
|
|
51
60
|
JSDOC_CONTINUATION_MARKER,
|
|
52
61
|
MARKDOWN_FILE_EXTENSION,
|
|
53
62
|
MINIMUM_IDENTIFIER_TOKEN_COUNT,
|
|
63
|
+
PROSE_WORD_PATTERN,
|
|
54
64
|
PYTHON_COMMENT_MARKER,
|
|
55
|
-
PYTHON_FILE_EXTENSION,
|
|
56
65
|
SNAKE_CASE_IDENTIFIER_PATTERN,
|
|
57
66
|
STRING_LITERAL_CONTENT_PATTERN,
|
|
58
67
|
TERMINOLOGY_FINDING_TEMPLATE,
|
|
59
68
|
TERMINOLOGY_SWEEP_DESCRIPTION,
|
|
60
|
-
TEST_DIRECTORY_PATH_SEGMENT,
|
|
61
|
-
TEST_FILE_NAME_PREFIX,
|
|
62
69
|
)
|
|
63
70
|
|
|
64
71
|
IdentifierTuple = tuple[str, ...]
|
|
@@ -115,35 +122,6 @@ def _file_extension(file_path: str) -> str:
|
|
|
115
122
|
return Path(file_path).suffix.lower()
|
|
116
123
|
|
|
117
124
|
|
|
118
|
-
def _is_test_file(file_path: str) -> bool:
|
|
119
|
-
"""Return True when the diff path is a test code file the sweep should skip.
|
|
120
|
-
|
|
121
|
-
A test code file's prose is scaffolding: its docstrings and string fixtures
|
|
122
|
-
name the identifiers under test on purpose, so scanning it only adds noise. A
|
|
123
|
-
Markdown or other non-code file is prose worth scanning whatever its name, so
|
|
124
|
-
a ``test_plan.md`` design doc is scanned rather than skipped.
|
|
125
|
-
|
|
126
|
-
Args:
|
|
127
|
-
file_path: The repository-relative diff path.
|
|
128
|
-
|
|
129
|
-
Returns:
|
|
130
|
-
True for a code file under a ``tests`` directory, a ``test_`` prefixed
|
|
131
|
-
code file, or a code file whose name carries a
|
|
132
|
-
``_test.``/``.test.``/``.spec.`` marker. False for any non-code file.
|
|
133
|
-
"""
|
|
134
|
-
if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
135
|
-
return False
|
|
136
|
-
normalized_path = file_path.replace("\\", "/").lower()
|
|
137
|
-
if TEST_DIRECTORY_PATH_SEGMENT in f"/{normalized_path}":
|
|
138
|
-
return True
|
|
139
|
-
basename = normalized_path.rsplit("/", 1)[-1]
|
|
140
|
-
if basename.startswith(TEST_FILE_NAME_PREFIX):
|
|
141
|
-
return True
|
|
142
|
-
return any(
|
|
143
|
-
each_marker in basename for each_marker in ALL_TEST_FILE_NAME_INFIX_MARKERS
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
|
|
147
125
|
def _identifier_token_tuple(identifier: str) -> IdentifierTuple:
|
|
148
126
|
"""Return the lowercase word tokens of a snake_case or camelCase identifier."""
|
|
149
127
|
if "_" in identifier:
|
|
@@ -171,35 +149,30 @@ def _identifier_tuples_in_text(code_text: str) -> list[IdentifierTuple]:
|
|
|
171
149
|
|
|
172
150
|
def _collect_introduced_identifiers(
|
|
173
151
|
all_added_lines: list[tuple[str, int, str]],
|
|
174
|
-
) ->
|
|
152
|
+
) -> frozenset[IdentifierTuple]:
|
|
175
153
|
"""Return the identifier tuples introduced on added code lines.
|
|
176
154
|
|
|
177
155
|
Args:
|
|
178
156
|
all_added_lines: Added-line triples from :func:`_parse_added_lines`.
|
|
179
157
|
|
|
180
158
|
Returns:
|
|
181
|
-
|
|
182
|
-
groups the tuples by their leading token so the near-miss check reaches
|
|
183
|
-
every candidate for a given prefix without rescanning the whole set.
|
|
159
|
+
Every multi-word identifier tuple appearing on an added code line.
|
|
184
160
|
"""
|
|
185
161
|
all_identifier_tuples: set[IdentifierTuple] = set()
|
|
186
162
|
for each_file_path, _, each_text in all_added_lines:
|
|
187
163
|
if _file_extension(each_file_path) in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
188
164
|
all_identifier_tuples.update(_identifier_tuples_in_text(each_text))
|
|
189
|
-
|
|
190
|
-
for each_tuple in all_identifier_tuples:
|
|
191
|
-
identifiers_by_first_token.setdefault(each_tuple[0], []).append(each_tuple)
|
|
192
|
-
return frozenset(all_identifier_tuples), identifiers_by_first_token
|
|
165
|
+
return frozenset(all_identifier_tuples)
|
|
193
166
|
|
|
194
167
|
|
|
195
168
|
def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
196
169
|
"""Return the prose fragments of an added line worth scanning for terms.
|
|
197
170
|
|
|
198
|
-
A Markdown line is prose
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
171
|
+
A Markdown line is prose with its inline-code spans removed, since a
|
|
172
|
+
backticked span names code verbatim. A code line contributes its comment
|
|
173
|
+
tail, its JSDoc continuation text, and the contents of its string
|
|
174
|
+
literals. A test module contributes its comment tail and JSDoc text only
|
|
175
|
+
— its string literals hold fixture data, not prose.
|
|
203
176
|
|
|
204
177
|
Args:
|
|
205
178
|
file_path: The path the added line belongs to.
|
|
@@ -210,20 +183,59 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
|
210
183
|
"""
|
|
211
184
|
extension = _file_extension(file_path)
|
|
212
185
|
if extension == MARKDOWN_FILE_EXTENSION:
|
|
213
|
-
return [line_text]
|
|
186
|
+
return [INLINE_CODE_SPAN_PATTERN.sub(" ", line_text)]
|
|
214
187
|
if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
215
188
|
return []
|
|
216
|
-
if extension == PYTHON_FILE_EXTENSION:
|
|
217
|
-
return []
|
|
218
189
|
all_fragments: list[str] = []
|
|
219
190
|
stripped_line = line_text.strip()
|
|
220
191
|
if stripped_line.startswith(JSDOC_CONTINUATION_MARKER):
|
|
221
192
|
all_fragments.append(stripped_line)
|
|
222
193
|
all_fragments.extend(_comment_fragments(line_text))
|
|
223
|
-
|
|
194
|
+
if not _is_test_file(file_path):
|
|
195
|
+
all_fragments.extend(_string_literal_fragments(line_text))
|
|
224
196
|
return all_fragments
|
|
225
197
|
|
|
226
198
|
|
|
199
|
+
def _is_test_file(file_path: str) -> bool:
|
|
200
|
+
"""Return whether a diff path names a test code module.
|
|
201
|
+
|
|
202
|
+
A test module's string literals hold fixture data — embedded diffs,
|
|
203
|
+
generated source, file trees — not documentation prose, so the sweep
|
|
204
|
+
reads only its comments and JSDoc lines. A ``quota.test.mjs``, a
|
|
205
|
+
``layout.spec.ts``, and a ``fixtures.py`` under a tests directory are all
|
|
206
|
+
test modules, so every one of these paths counts::
|
|
207
|
+
|
|
208
|
+
tests/fixtures.py -- under a tests/ directory
|
|
209
|
+
api/test_quota.py -- test_ prefixed stem
|
|
210
|
+
api/quota_test.py -- _test suffixed stem
|
|
211
|
+
api/quota.test.mjs -- .test. name marker
|
|
212
|
+
api/layout.spec.ts -- .spec. name marker
|
|
213
|
+
|
|
214
|
+
A non-code path (a Markdown design doc) is prose worth scanning whatever
|
|
215
|
+
its name, so it is never a test module here.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
file_path: The diff path of the added line's file.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True for a code file under a ``tests`` directory, or one whose name
|
|
222
|
+
carries a ``test_`` prefix, a ``_test`` suffix, or a
|
|
223
|
+
``_test.``/``.test.``/``.spec.`` marker. False for any other path.
|
|
224
|
+
"""
|
|
225
|
+
if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
226
|
+
return False
|
|
227
|
+
normalized_path = file_path.replace("\\", "/").lower()
|
|
228
|
+
if TEST_DIRECTORY_PATH_SEGMENT in f"/{normalized_path}":
|
|
229
|
+
return True
|
|
230
|
+
basename = normalized_path.rsplit("/", 1)[-1]
|
|
231
|
+
stem = Path(normalized_path).stem
|
|
232
|
+
if stem.startswith(TEST_FILE_PREFIX) or stem.endswith(TEST_FILE_SUFFIX):
|
|
233
|
+
return True
|
|
234
|
+
return any(
|
|
235
|
+
each_marker in basename for each_marker in ALL_TEST_FILE_NAME_INFIX_MARKERS
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
227
239
|
def _comment_fragments(line_text: str) -> list[str]:
|
|
228
240
|
"""Return the comment tail of a code line, or an empty list when absent."""
|
|
229
241
|
all_fragments: list[str] = []
|
|
@@ -241,7 +253,14 @@ def _comment_fragments(line_text: str) -> list[str]:
|
|
|
241
253
|
|
|
242
254
|
|
|
243
255
|
def _string_literal_fragments(line_text: str) -> list[str]:
|
|
244
|
-
"""Return the contents of every quoted string literal on a code line.
|
|
256
|
+
"""Return the contents of every quoted string literal on a code line.
|
|
257
|
+
|
|
258
|
+
Escape sequences inside a literal (``\\n``, ``\\t``) are replaced with
|
|
259
|
+
spaces before the words are read, so an escape letter never glues onto a
|
|
260
|
+
neighbouring word as a phantom prose term. A literal left with no
|
|
261
|
+
whitespace after that replacement is an identifier-shaped value — a UID,
|
|
262
|
+
a key, a path — not prose, and contributes nothing.
|
|
263
|
+
"""
|
|
245
264
|
all_fragments: list[str] = []
|
|
246
265
|
for each_match in STRING_LITERAL_CONTENT_PATTERN.finditer(line_text):
|
|
247
266
|
each_content = next(
|
|
@@ -252,6 +271,10 @@ def _string_literal_fragments(line_text: str) -> list[str]:
|
|
|
252
271
|
),
|
|
253
272
|
"",
|
|
254
273
|
)
|
|
274
|
+
for each_escape in ALL_STRING_ESCAPE_SEQUENCES:
|
|
275
|
+
each_content = each_content.replace(each_escape, " ")
|
|
276
|
+
if not any(each_character.isspace() for each_character in each_content):
|
|
277
|
+
continue
|
|
255
278
|
all_fragments.append(each_content)
|
|
256
279
|
return all_fragments
|
|
257
280
|
|
|
@@ -266,6 +289,36 @@ def _hyphenated_candidates(fragment: str) -> list[tuple[str, IdentifierTuple]]:
|
|
|
266
289
|
return all_candidates
|
|
267
290
|
|
|
268
291
|
|
|
292
|
+
def _spaced_candidates(
|
|
293
|
+
fragment: str,
|
|
294
|
+
identifiers_by_first_token: dict[str, list[IdentifierTuple]],
|
|
295
|
+
) -> list[tuple[str, IdentifierTuple]]:
|
|
296
|
+
"""Return spaced word windows whose first word matches an identifier prefix.
|
|
297
|
+
|
|
298
|
+
Only a window anchored at a known identifier prefix and sized to one of that
|
|
299
|
+
prefix's identifiers is returned, which bounds the windows to the shapes the
|
|
300
|
+
near-miss check can act on.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
fragment: The prose fragment to scan.
|
|
304
|
+
identifiers_by_first_token: Identifier tuples grouped by leading token.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
``(display, token_tuple)`` pairs for each candidate window.
|
|
308
|
+
"""
|
|
309
|
+
all_words = [
|
|
310
|
+
each_word.lower() for each_word in PROSE_WORD_PATTERN.findall(fragment)
|
|
311
|
+
]
|
|
312
|
+
all_candidates: list[tuple[str, IdentifierTuple]] = []
|
|
313
|
+
for each_index, each_word in enumerate(all_words):
|
|
314
|
+
for each_identifier in identifiers_by_first_token.get(each_word, []):
|
|
315
|
+
window = tuple(all_words[each_index : each_index + len(each_identifier)])
|
|
316
|
+
if len(window) == len(each_identifier):
|
|
317
|
+
display = PROSE_WINDOW_WORD_SEPARATOR.join(window)
|
|
318
|
+
all_candidates.append((display, window))
|
|
319
|
+
return all_candidates
|
|
320
|
+
|
|
321
|
+
|
|
269
322
|
def _tokens_are_plural_variants(first_word: str, second_word: str) -> bool:
|
|
270
323
|
"""Return whether two words are singular/plural forms of one word.
|
|
271
324
|
|
|
@@ -292,10 +345,41 @@ def _tuples_match_ignoring_plural(
|
|
|
292
345
|
)
|
|
293
346
|
|
|
294
347
|
|
|
348
|
+
def _word_is_identifier_vocabulary(
|
|
349
|
+
prose_word: str, all_identifier_tokens: frozenset[str]
|
|
350
|
+
) -> bool:
|
|
351
|
+
"""Return whether a prose word is a token of any introduced identifier.
|
|
352
|
+
|
|
353
|
+
A singular/plural form of an identifier token counts as the same word,
|
|
354
|
+
so ``uids`` matches an identifier token ``uid``.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
prose_word: The lowercase prose word to look up.
|
|
358
|
+
all_identifier_tokens: Every token of every introduced identifier.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
True when the word, or a plural variant of it, is a known token.
|
|
362
|
+
"""
|
|
363
|
+
if prose_word in all_identifier_tokens:
|
|
364
|
+
return True
|
|
365
|
+
if prose_word.endswith("y") and prose_word[:-1] + "ies" in all_identifier_tokens:
|
|
366
|
+
return True
|
|
367
|
+
if prose_word + "s" in all_identifier_tokens or prose_word + "es" in all_identifier_tokens:
|
|
368
|
+
return True
|
|
369
|
+
if prose_word.endswith("s") and prose_word[:-1] in all_identifier_tokens:
|
|
370
|
+
return True
|
|
371
|
+
if prose_word.endswith("es") and prose_word[:-2] in all_identifier_tokens:
|
|
372
|
+
return True
|
|
373
|
+
if prose_word.endswith("ies") and prose_word[:-3] + "y" in all_identifier_tokens:
|
|
374
|
+
return True
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
|
|
295
378
|
def _near_miss_identifier(
|
|
296
379
|
candidate_tuple: IdentifierTuple,
|
|
297
380
|
all_identifier_tuples: frozenset[IdentifierTuple],
|
|
298
381
|
identifiers_by_first_token: dict[str, list[IdentifierTuple]],
|
|
382
|
+
all_identifier_tokens: frozenset[str],
|
|
299
383
|
) -> IdentifierTuple | None:
|
|
300
384
|
"""Return an identifier the candidate near-misses, or None when it does not.
|
|
301
385
|
|
|
@@ -303,20 +387,27 @@ def _near_miss_identifier(
|
|
|
303
387
|
candidate_tuple: The prose term's lowercase token tuple.
|
|
304
388
|
all_identifier_tuples: Every introduced identifier tuple.
|
|
305
389
|
identifiers_by_first_token: Identifier tuples grouped by leading token.
|
|
390
|
+
all_identifier_tokens: Every token of every identifier on added code
|
|
391
|
+
lines, used to spare prose built from real code vocabulary.
|
|
306
392
|
|
|
307
393
|
Returns:
|
|
308
|
-
The first identifier sharing the candidate's leading token and length
|
|
309
|
-
differing in a later token
|
|
310
|
-
|
|
311
|
-
|
|
394
|
+
The first identifier sharing the candidate's leading token and length
|
|
395
|
+
but differing in a later token. None marks the candidate ordinary
|
|
396
|
+
prose instead, on any of these grounds: it matches an identifier
|
|
397
|
+
exactly; it shares no prefix with one; it ends in a common English
|
|
398
|
+
compound tail word (``read-only``, ``data-driven``); it contains an
|
|
399
|
+
English stopword (``to a``, ``each image``); it differs from the
|
|
312
400
|
identifier only by a singular/plural form of one or more tokens
|
|
313
|
-
(``test files`` against ``test_file``)
|
|
314
|
-
|
|
401
|
+
(``test files`` against ``test_file``); or every diverging word is
|
|
402
|
+
itself a token of some introduced identifier (``target box`` when
|
|
403
|
+
``box_height`` is also in the diff).
|
|
315
404
|
"""
|
|
316
405
|
if not candidate_tuple or candidate_tuple in all_identifier_tuples:
|
|
317
406
|
return None
|
|
318
407
|
if candidate_tuple[-1] in ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS:
|
|
319
408
|
return None
|
|
409
|
+
if any(each_word in ALL_PROSE_STOPWORD_TOKENS for each_word in candidate_tuple):
|
|
410
|
+
return None
|
|
320
411
|
for each_identifier in identifiers_by_first_token.get(candidate_tuple[0], []):
|
|
321
412
|
if len(each_identifier) != len(candidate_tuple):
|
|
322
413
|
continue
|
|
@@ -324,6 +415,16 @@ def _near_miss_identifier(
|
|
|
324
415
|
continue
|
|
325
416
|
if _tuples_match_ignoring_plural(each_identifier, candidate_tuple):
|
|
326
417
|
continue
|
|
418
|
+
diverging_words = [
|
|
419
|
+
each_word
|
|
420
|
+
for each_word, each_token in zip(candidate_tuple, each_identifier)
|
|
421
|
+
if not _tokens_are_plural_variants(each_word, each_token)
|
|
422
|
+
]
|
|
423
|
+
if all(
|
|
424
|
+
_word_is_identifier_vocabulary(each_word, all_identifier_tokens)
|
|
425
|
+
for each_word in diverging_words
|
|
426
|
+
):
|
|
427
|
+
continue
|
|
327
428
|
return each_identifier
|
|
328
429
|
return None
|
|
329
430
|
|
|
@@ -334,6 +435,7 @@ def _findings_for_line(
|
|
|
334
435
|
line_text: str,
|
|
335
436
|
all_identifier_tuples: frozenset[IdentifierTuple],
|
|
336
437
|
identifiers_by_first_token: dict[str, list[IdentifierTuple]],
|
|
438
|
+
all_identifier_tokens: frozenset[str],
|
|
337
439
|
) -> list[str]:
|
|
338
440
|
"""Return the near-miss findings for one added line.
|
|
339
441
|
|
|
@@ -343,6 +445,8 @@ def _findings_for_line(
|
|
|
343
445
|
line_text: The added line's text without its diff marker.
|
|
344
446
|
all_identifier_tuples: Every introduced identifier tuple.
|
|
345
447
|
identifiers_by_first_token: Identifier tuples grouped by leading token.
|
|
448
|
+
all_identifier_tokens: Every token of every identifier on added code
|
|
449
|
+
lines, used to spare prose built from real code vocabulary.
|
|
346
450
|
|
|
347
451
|
Returns:
|
|
348
452
|
One finding string per distinct near-miss term on the line.
|
|
@@ -351,9 +455,13 @@ def _findings_for_line(
|
|
|
351
455
|
reported_tuples: set[IdentifierTuple] = set()
|
|
352
456
|
for each_fragment in _prose_fragments(file_path, line_text):
|
|
353
457
|
all_candidates = _hyphenated_candidates(each_fragment)
|
|
458
|
+
all_candidates += _spaced_candidates(each_fragment, identifiers_by_first_token)
|
|
354
459
|
for each_display, each_tuple in all_candidates:
|
|
355
460
|
matched_identifier = _near_miss_identifier(
|
|
356
|
-
each_tuple,
|
|
461
|
+
each_tuple,
|
|
462
|
+
all_identifier_tuples,
|
|
463
|
+
identifiers_by_first_token,
|
|
464
|
+
all_identifier_tokens,
|
|
357
465
|
)
|
|
358
466
|
if matched_identifier is None or each_tuple in reported_tuples:
|
|
359
467
|
continue
|
|
@@ -363,31 +471,43 @@ def _findings_for_line(
|
|
|
363
471
|
file_path=file_path,
|
|
364
472
|
line_number=line_number,
|
|
365
473
|
candidate=each_display,
|
|
366
|
-
identifier=
|
|
474
|
+
identifier=IDENTIFIER_TOKEN_SEPARATOR.join(matched_identifier),
|
|
367
475
|
)
|
|
368
476
|
)
|
|
369
477
|
return all_findings
|
|
370
478
|
|
|
371
479
|
|
|
372
|
-
def sweep_diff(
|
|
480
|
+
def sweep_diff(
|
|
481
|
+
diff_text: str,
|
|
482
|
+
all_preexisting_identifier_tuples: frozenset[IdentifierTuple] = frozenset(),
|
|
483
|
+
) -> list[str]:
|
|
373
484
|
"""Return every terminology near-miss finding for a unified diff.
|
|
374
485
|
|
|
375
486
|
Args:
|
|
376
487
|
diff_text: The unified-diff text to sweep.
|
|
488
|
+
all_preexisting_identifier_tuples: Token tuples of added-line identifiers
|
|
489
|
+
the base tree already names. A pre-existing identifier is not one
|
|
490
|
+
the diff introduces, so no prose is flagged against it. Its
|
|
491
|
+
tokens still count as code vocabulary.
|
|
377
492
|
|
|
378
493
|
Returns:
|
|
379
494
|
One finding string per near-miss term on an added prose line.
|
|
380
495
|
"""
|
|
381
|
-
all_added_lines =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
496
|
+
all_added_lines = _parse_added_lines(diff_text)
|
|
497
|
+
all_identifier_tuples = _collect_introduced_identifiers(all_added_lines)
|
|
498
|
+
all_identifier_tokens = frozenset(
|
|
499
|
+
each_token for each_tuple in all_identifier_tuples for each_token in each_tuple
|
|
500
|
+
)
|
|
501
|
+
introduced_tuples = frozenset(
|
|
502
|
+
each_tuple
|
|
503
|
+
for each_tuple in all_identifier_tuples
|
|
504
|
+
if each_tuple not in all_preexisting_identifier_tuples
|
|
388
505
|
)
|
|
389
|
-
if not
|
|
506
|
+
if not introduced_tuples:
|
|
390
507
|
return []
|
|
508
|
+
introduced_by_first_token: dict[str, list[IdentifierTuple]] = {}
|
|
509
|
+
for each_tuple in introduced_tuples:
|
|
510
|
+
introduced_by_first_token.setdefault(each_tuple[0], []).append(each_tuple)
|
|
391
511
|
all_findings: list[str] = []
|
|
392
512
|
for each_file_path, each_line_number, each_text in all_added_lines:
|
|
393
513
|
all_findings.extend(
|
|
@@ -395,20 +515,25 @@ def sweep_diff(diff_text: str) -> list[str]:
|
|
|
395
515
|
each_file_path,
|
|
396
516
|
each_line_number,
|
|
397
517
|
each_text,
|
|
398
|
-
|
|
399
|
-
|
|
518
|
+
introduced_tuples,
|
|
519
|
+
introduced_by_first_token,
|
|
520
|
+
all_identifier_tokens,
|
|
400
521
|
)
|
|
401
522
|
)
|
|
402
523
|
return all_findings
|
|
403
524
|
|
|
404
525
|
|
|
405
|
-
def
|
|
526
|
+
def repository_environment() -> dict[str, str]:
|
|
406
527
|
"""Return the process environment with every GIT_ variable removed.
|
|
407
528
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
529
|
+
The sweep and the commit gate name their repository through an explicit
|
|
530
|
+
root argument. An inherited GIT_DIR or GIT_WORK_TREE would override
|
|
531
|
+
that root and point git at a different repository, so each spawned
|
|
532
|
+
subprocess runs with a scrubbed environment.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
A copy of ``os.environ`` without any variable whose name starts
|
|
536
|
+
with ``GIT_``.
|
|
412
537
|
"""
|
|
413
538
|
return {
|
|
414
539
|
each_key: each_setting
|
|
@@ -417,9 +542,75 @@ def _git_environment() -> dict[str, str]:
|
|
|
417
542
|
}
|
|
418
543
|
|
|
419
544
|
|
|
545
|
+
def _identifier_names_on_added_code_lines(diff_text: str) -> frozenset[str]:
|
|
546
|
+
"""Return every multi-word identifier name on the diff's added code lines.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
diff_text: The unified-diff text to scan.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
The distinct snake_case and camelCase names of two or more tokens.
|
|
553
|
+
"""
|
|
554
|
+
all_names: set[str] = set()
|
|
555
|
+
for each_file_path, _, each_text in _parse_added_lines(diff_text):
|
|
556
|
+
if _file_extension(each_file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
557
|
+
continue
|
|
558
|
+
all_found_names = SNAKE_CASE_IDENTIFIER_PATTERN.findall(each_text)
|
|
559
|
+
all_found_names += CAMEL_CASE_IDENTIFIER_PATTERN.findall(each_text)
|
|
560
|
+
for each_name in all_found_names:
|
|
561
|
+
token_tuple = _identifier_token_tuple(each_name)
|
|
562
|
+
if len(token_tuple) >= MINIMUM_IDENTIFIER_TOKEN_COUNT:
|
|
563
|
+
all_names.add(each_name)
|
|
564
|
+
return frozenset(all_names)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _base_tree_names(
|
|
568
|
+
repository_root: Path, all_names: frozenset[str]
|
|
569
|
+
) -> frozenset[str]:
|
|
570
|
+
"""Return the names the repository's base tree already contains.
|
|
571
|
+
|
|
572
|
+
Each name is looked up as a whole word in the base revision's tree. A
|
|
573
|
+
repository with no commits yet, or a lookup that git cannot run, reports
|
|
574
|
+
the name as absent, so the sweep then treats it as newly introduced —
|
|
575
|
+
the behaviour the sweep has with no base-tree check at all.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
repository_root: The repository root the lookups run in.
|
|
579
|
+
all_names: The identifier names to look up.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
The subset of names present in the base tree.
|
|
583
|
+
"""
|
|
584
|
+
all_present_names: set[str] = set()
|
|
585
|
+
for each_name in sorted(all_names):
|
|
586
|
+
try:
|
|
587
|
+
grep_process = subprocess.run(
|
|
588
|
+
[
|
|
589
|
+
*ALL_GIT_GREP_BASE_TREE_COMMAND_PREFIX,
|
|
590
|
+
each_name,
|
|
591
|
+
GIT_BASE_TREE_REVISION,
|
|
592
|
+
],
|
|
593
|
+
cwd=str(repository_root),
|
|
594
|
+
capture_output=True,
|
|
595
|
+
text=True,
|
|
596
|
+
timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
597
|
+
check=False,
|
|
598
|
+
env=repository_environment(),
|
|
599
|
+
)
|
|
600
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
601
|
+
continue
|
|
602
|
+
if grep_process.returncode == 0:
|
|
603
|
+
all_present_names.add(each_name)
|
|
604
|
+
return frozenset(all_present_names)
|
|
605
|
+
|
|
606
|
+
|
|
420
607
|
def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
421
608
|
"""Return terminology near-miss findings for a repository's staged diff.
|
|
422
609
|
|
|
610
|
+
An identifier the base tree already names is not one the staged diff
|
|
611
|
+
introduces, so no prose is flagged against it — only genuinely new
|
|
612
|
+
identifiers are swept.
|
|
613
|
+
|
|
423
614
|
Args:
|
|
424
615
|
repository_root: The repository root the staged diff is read from.
|
|
425
616
|
|
|
@@ -436,11 +627,16 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
|
436
627
|
errors="replace",
|
|
437
628
|
timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
438
629
|
check=False,
|
|
439
|
-
env=
|
|
630
|
+
env=repository_environment(),
|
|
440
631
|
)
|
|
441
632
|
if diff_process.returncode != 0:
|
|
442
633
|
return []
|
|
443
|
-
|
|
634
|
+
all_added_names = _identifier_names_on_added_code_lines(diff_process.stdout)
|
|
635
|
+
preexisting_tuples = frozenset(
|
|
636
|
+
_identifier_token_tuple(each_name)
|
|
637
|
+
for each_name in _base_tree_names(repository_root, all_added_names)
|
|
638
|
+
)
|
|
639
|
+
return sweep_diff(diff_process.stdout, preexisting_tuples)
|
|
444
640
|
|
|
445
641
|
|
|
446
642
|
def _read_diff_text(diff_file_path: str | None) -> str:
|