claude-dev-env 1.84.0 → 1.86.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/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/code_rules_docstrings.py +17 -13
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_naming_collection.py +5 -3
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -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/blocking_check_limits.py +1 -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/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/re-stage-before-commit.md +31 -0
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +46 -3
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/pr-converge/SKILL.md +27 -1
- package/skills/pr-converge/reference/state-schema.md +12 -0
|
@@ -1,40 +1,26 @@
|
|
|
1
|
-
"""Flag prose
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
gate nothing actionable.
|
|
21
|
-
|
|
22
|
-
A shared leading token alone is too weak a signal: ordinary English compounds
|
|
23
|
-
such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
|
|
24
|
-
(``read_config``, ``data_source``) and would block a commit falsely. To keep the
|
|
25
|
-
grounding case (``premium-request`` against ``premium_interactions``) firing
|
|
26
|
-
while sparing those compounds, a candidate whose final token is a common English
|
|
27
|
-
compound tail word (``only``, ``driven``, ``safe``, and the rest listed in
|
|
28
|
-
``ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS``) is treated as ordinary prose, not a
|
|
29
|
-
near-miss.
|
|
30
|
-
|
|
31
|
-
Ordinary singular/plural prose is spared the same way. A candidate that differs
|
|
32
|
-
from an identifier only by a singular/plural form of one or more tokens
|
|
33
|
-
(``test files`` against ``test_file``, ``retry policies`` against
|
|
34
|
-
``retry_policy``) is treated as the same term, not a near-miss.
|
|
1
|
+
"""Flag a prose term that names a code identifier a hair differently.
|
|
2
|
+
|
|
3
|
+
A change adds the field ``premium_interactions`` while the same branch's docs
|
|
4
|
+
call it the ``premium-request`` budget: one thing, two names, and a reader who
|
|
5
|
+
searches one never finds the other.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
code adds: premium_interactions the identifier
|
|
10
|
+
prose adds: the premium-request budget a hyphenated term
|
|
11
|
+
flag: premium-request vs premium_interactions -- shared first word, tail diverges
|
|
12
|
+
ok: premium-interactions -- exact hyphen form, agrees
|
|
13
|
+
ok: read-only, test files, to a file -- English compound, plural, or stopword
|
|
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.
|
|
35
20
|
"""
|
|
36
21
|
|
|
37
22
|
import argparse
|
|
23
|
+
import os
|
|
38
24
|
import subprocess
|
|
39
25
|
import sys
|
|
40
26
|
from pathlib import Path
|
|
@@ -45,6 +31,16 @@ if parent_directory not in sys.path:
|
|
|
45
31
|
|
|
46
32
|
from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
47
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,
|
|
48
44
|
ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
|
|
49
45
|
ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
|
|
50
46
|
ALL_SWEEP_CODE_FILE_EXTENSIONS,
|
|
@@ -59,10 +55,12 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
|
59
55
|
DIFF_REMOVED_LINE_PREFIX,
|
|
60
56
|
GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
61
57
|
HYPHENATED_PROSE_TOKEN_PATTERN,
|
|
58
|
+
INLINE_CODE_SPAN_PATTERN,
|
|
62
59
|
JAVASCRIPT_LINE_COMMENT_MARKER,
|
|
63
60
|
JSDOC_CONTINUATION_MARKER,
|
|
64
61
|
MARKDOWN_FILE_EXTENSION,
|
|
65
62
|
MINIMUM_IDENTIFIER_TOKEN_COUNT,
|
|
63
|
+
PROSE_WORD_PATTERN,
|
|
66
64
|
PYTHON_COMMENT_MARKER,
|
|
67
65
|
SNAKE_CASE_IDENTIFIER_PATTERN,
|
|
68
66
|
STRING_LITERAL_CONTENT_PATTERN,
|
|
@@ -151,32 +149,30 @@ def _identifier_tuples_in_text(code_text: str) -> list[IdentifierTuple]:
|
|
|
151
149
|
|
|
152
150
|
def _collect_introduced_identifiers(
|
|
153
151
|
all_added_lines: list[tuple[str, int, str]],
|
|
154
|
-
) ->
|
|
152
|
+
) -> frozenset[IdentifierTuple]:
|
|
155
153
|
"""Return the identifier tuples introduced on added code lines.
|
|
156
154
|
|
|
157
155
|
Args:
|
|
158
156
|
all_added_lines: Added-line triples from :func:`_parse_added_lines`.
|
|
159
157
|
|
|
160
158
|
Returns:
|
|
161
|
-
|
|
162
|
-
groups the tuples by their leading token so the near-miss check reaches
|
|
163
|
-
every candidate for a given prefix without rescanning the whole set.
|
|
159
|
+
Every multi-word identifier tuple appearing on an added code line.
|
|
164
160
|
"""
|
|
165
161
|
all_identifier_tuples: set[IdentifierTuple] = set()
|
|
166
162
|
for each_file_path, _, each_text in all_added_lines:
|
|
167
163
|
if _file_extension(each_file_path) in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
168
164
|
all_identifier_tuples.update(_identifier_tuples_in_text(each_text))
|
|
169
|
-
|
|
170
|
-
for each_tuple in all_identifier_tuples:
|
|
171
|
-
identifiers_by_first_token.setdefault(each_tuple[0], []).append(each_tuple)
|
|
172
|
-
return frozenset(all_identifier_tuples), identifiers_by_first_token
|
|
165
|
+
return frozenset(all_identifier_tuples)
|
|
173
166
|
|
|
174
167
|
|
|
175
168
|
def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
176
169
|
"""Return the prose fragments of an added line worth scanning for terms.
|
|
177
170
|
|
|
178
|
-
A Markdown line is prose
|
|
179
|
-
|
|
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.
|
|
180
176
|
|
|
181
177
|
Args:
|
|
182
178
|
file_path: The path the added line belongs to.
|
|
@@ -185,19 +181,61 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
|
185
181
|
Returns:
|
|
186
182
|
The prose fragments to scan for near-miss terms.
|
|
187
183
|
"""
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
184
|
+
extension = _file_extension(file_path)
|
|
185
|
+
if extension == MARKDOWN_FILE_EXTENSION:
|
|
186
|
+
return [INLINE_CODE_SPAN_PATTERN.sub(" ", line_text)]
|
|
187
|
+
if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
191
188
|
return []
|
|
192
189
|
all_fragments: list[str] = []
|
|
193
190
|
stripped_line = line_text.strip()
|
|
194
191
|
if stripped_line.startswith(JSDOC_CONTINUATION_MARKER):
|
|
195
192
|
all_fragments.append(stripped_line)
|
|
196
193
|
all_fragments.extend(_comment_fragments(line_text))
|
|
197
|
-
|
|
194
|
+
if not _is_test_file(file_path):
|
|
195
|
+
all_fragments.extend(_string_literal_fragments(line_text))
|
|
198
196
|
return all_fragments
|
|
199
197
|
|
|
200
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
|
+
|
|
201
239
|
def _comment_fragments(line_text: str) -> list[str]:
|
|
202
240
|
"""Return the comment tail of a code line, or an empty list when absent."""
|
|
203
241
|
all_fragments: list[str] = []
|
|
@@ -215,7 +253,14 @@ def _comment_fragments(line_text: str) -> list[str]:
|
|
|
215
253
|
|
|
216
254
|
|
|
217
255
|
def _string_literal_fragments(line_text: str) -> list[str]:
|
|
218
|
-
"""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
|
+
"""
|
|
219
264
|
all_fragments: list[str] = []
|
|
220
265
|
for each_match in STRING_LITERAL_CONTENT_PATTERN.finditer(line_text):
|
|
221
266
|
each_content = next(
|
|
@@ -226,6 +271,10 @@ def _string_literal_fragments(line_text: str) -> list[str]:
|
|
|
226
271
|
),
|
|
227
272
|
"",
|
|
228
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
|
|
229
278
|
all_fragments.append(each_content)
|
|
230
279
|
return all_fragments
|
|
231
280
|
|
|
@@ -240,6 +289,36 @@ def _hyphenated_candidates(fragment: str) -> list[tuple[str, IdentifierTuple]]:
|
|
|
240
289
|
return all_candidates
|
|
241
290
|
|
|
242
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
|
+
|
|
243
322
|
def _tokens_are_plural_variants(first_word: str, second_word: str) -> bool:
|
|
244
323
|
"""Return whether two words are singular/plural forms of one word.
|
|
245
324
|
|
|
@@ -266,10 +345,41 @@ def _tuples_match_ignoring_plural(
|
|
|
266
345
|
)
|
|
267
346
|
|
|
268
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
|
+
|
|
269
378
|
def _near_miss_identifier(
|
|
270
379
|
candidate_tuple: IdentifierTuple,
|
|
271
380
|
all_identifier_tuples: frozenset[IdentifierTuple],
|
|
272
381
|
identifiers_by_first_token: dict[str, list[IdentifierTuple]],
|
|
382
|
+
all_identifier_tokens: frozenset[str],
|
|
273
383
|
) -> IdentifierTuple | None:
|
|
274
384
|
"""Return an identifier the candidate near-misses, or None when it does not.
|
|
275
385
|
|
|
@@ -277,20 +387,27 @@ def _near_miss_identifier(
|
|
|
277
387
|
candidate_tuple: The prose term's lowercase token tuple.
|
|
278
388
|
all_identifier_tuples: Every introduced identifier tuple.
|
|
279
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.
|
|
280
392
|
|
|
281
393
|
Returns:
|
|
282
|
-
The first identifier sharing the candidate's leading token and length
|
|
283
|
-
differing in a later token
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
286
400
|
identifier only by a singular/plural form of one or more tokens
|
|
287
|
-
(``test files`` against ``test_file``)
|
|
288
|
-
|
|
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).
|
|
289
404
|
"""
|
|
290
405
|
if not candidate_tuple or candidate_tuple in all_identifier_tuples:
|
|
291
406
|
return None
|
|
292
407
|
if candidate_tuple[-1] in ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS:
|
|
293
408
|
return None
|
|
409
|
+
if any(each_word in ALL_PROSE_STOPWORD_TOKENS for each_word in candidate_tuple):
|
|
410
|
+
return None
|
|
294
411
|
for each_identifier in identifiers_by_first_token.get(candidate_tuple[0], []):
|
|
295
412
|
if len(each_identifier) != len(candidate_tuple):
|
|
296
413
|
continue
|
|
@@ -298,6 +415,16 @@ def _near_miss_identifier(
|
|
|
298
415
|
continue
|
|
299
416
|
if _tuples_match_ignoring_plural(each_identifier, candidate_tuple):
|
|
300
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
|
|
301
428
|
return each_identifier
|
|
302
429
|
return None
|
|
303
430
|
|
|
@@ -308,6 +435,7 @@ def _findings_for_line(
|
|
|
308
435
|
line_text: str,
|
|
309
436
|
all_identifier_tuples: frozenset[IdentifierTuple],
|
|
310
437
|
identifiers_by_first_token: dict[str, list[IdentifierTuple]],
|
|
438
|
+
all_identifier_tokens: frozenset[str],
|
|
311
439
|
) -> list[str]:
|
|
312
440
|
"""Return the near-miss findings for one added line.
|
|
313
441
|
|
|
@@ -317,6 +445,8 @@ def _findings_for_line(
|
|
|
317
445
|
line_text: The added line's text without its diff marker.
|
|
318
446
|
all_identifier_tuples: Every introduced identifier tuple.
|
|
319
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.
|
|
320
450
|
|
|
321
451
|
Returns:
|
|
322
452
|
One finding string per distinct near-miss term on the line.
|
|
@@ -325,9 +455,13 @@ def _findings_for_line(
|
|
|
325
455
|
reported_tuples: set[IdentifierTuple] = set()
|
|
326
456
|
for each_fragment in _prose_fragments(file_path, line_text):
|
|
327
457
|
all_candidates = _hyphenated_candidates(each_fragment)
|
|
458
|
+
all_candidates += _spaced_candidates(each_fragment, identifiers_by_first_token)
|
|
328
459
|
for each_display, each_tuple in all_candidates:
|
|
329
460
|
matched_identifier = _near_miss_identifier(
|
|
330
|
-
each_tuple,
|
|
461
|
+
each_tuple,
|
|
462
|
+
all_identifier_tuples,
|
|
463
|
+
identifiers_by_first_token,
|
|
464
|
+
all_identifier_tokens,
|
|
331
465
|
)
|
|
332
466
|
if matched_identifier is None or each_tuple in reported_tuples:
|
|
333
467
|
continue
|
|
@@ -337,27 +471,43 @@ def _findings_for_line(
|
|
|
337
471
|
file_path=file_path,
|
|
338
472
|
line_number=line_number,
|
|
339
473
|
candidate=each_display,
|
|
340
|
-
identifier=
|
|
474
|
+
identifier=IDENTIFIER_TOKEN_SEPARATOR.join(matched_identifier),
|
|
341
475
|
)
|
|
342
476
|
)
|
|
343
477
|
return all_findings
|
|
344
478
|
|
|
345
479
|
|
|
346
|
-
def sweep_diff(
|
|
480
|
+
def sweep_diff(
|
|
481
|
+
diff_text: str,
|
|
482
|
+
all_preexisting_identifier_tuples: frozenset[IdentifierTuple] = frozenset(),
|
|
483
|
+
) -> list[str]:
|
|
347
484
|
"""Return every terminology near-miss finding for a unified diff.
|
|
348
485
|
|
|
349
486
|
Args:
|
|
350
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.
|
|
351
492
|
|
|
352
493
|
Returns:
|
|
353
494
|
One finding string per near-miss term on an added prose line.
|
|
354
495
|
"""
|
|
355
496
|
all_added_lines = _parse_added_lines(diff_text)
|
|
356
|
-
all_identifier_tuples
|
|
357
|
-
|
|
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
|
|
358
505
|
)
|
|
359
|
-
if not
|
|
506
|
+
if not introduced_tuples:
|
|
360
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)
|
|
361
511
|
all_findings: list[str] = []
|
|
362
512
|
for each_file_path, each_line_number, each_text in all_added_lines:
|
|
363
513
|
all_findings.extend(
|
|
@@ -365,16 +515,102 @@ def sweep_diff(diff_text: str) -> list[str]:
|
|
|
365
515
|
each_file_path,
|
|
366
516
|
each_line_number,
|
|
367
517
|
each_text,
|
|
368
|
-
|
|
369
|
-
|
|
518
|
+
introduced_tuples,
|
|
519
|
+
introduced_by_first_token,
|
|
520
|
+
all_identifier_tokens,
|
|
370
521
|
)
|
|
371
522
|
)
|
|
372
523
|
return all_findings
|
|
373
524
|
|
|
374
525
|
|
|
526
|
+
def repository_environment() -> dict[str, str]:
|
|
527
|
+
"""Return the process environment with every GIT_ variable removed.
|
|
528
|
+
|
|
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_``.
|
|
537
|
+
"""
|
|
538
|
+
return {
|
|
539
|
+
each_key: each_setting
|
|
540
|
+
for each_key, each_setting in os.environ.items()
|
|
541
|
+
if not each_key.startswith("GIT_")
|
|
542
|
+
}
|
|
543
|
+
|
|
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
|
+
|
|
375
607
|
def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
376
608
|
"""Return terminology near-miss findings for a repository's staged diff.
|
|
377
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
|
+
|
|
378
614
|
Args:
|
|
379
615
|
repository_root: The repository root the staged diff is read from.
|
|
380
616
|
|
|
@@ -391,10 +627,16 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
|
391
627
|
errors="replace",
|
|
392
628
|
timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
393
629
|
check=False,
|
|
630
|
+
env=repository_environment(),
|
|
394
631
|
)
|
|
395
632
|
if diff_process.returncode != 0:
|
|
396
633
|
return []
|
|
397
|
-
|
|
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)
|
|
398
640
|
|
|
399
641
|
|
|
400
642
|
def _read_diff_text(diff_file_path: str | None) -> str:
|
|
@@ -64,6 +64,7 @@ def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
|
|
|
64
64
|
encoding="utf-8",
|
|
65
65
|
errors="replace",
|
|
66
66
|
check=True,
|
|
67
|
+
env=gate_module.repository_environment(),
|
|
67
68
|
)
|
|
68
69
|
return completion.stdout
|
|
69
70
|
|
|
@@ -2329,3 +2330,91 @@ def test_parse_arguments_applies_documented_defaults() -> None:
|
|
|
2329
2330
|
assert parsed_arguments.repo_root is None
|
|
2330
2331
|
assert parsed_arguments.only_under == []
|
|
2331
2332
|
assert parsed_arguments.paths == []
|
|
2333
|
+
|
|
2334
|
+
|
|
2335
|
+
def test_staged_mode_resolves_scan_roots_from_outside_the_repository(
|
|
2336
|
+
temporary_git_repository: Path,
|
|
2337
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2338
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
2339
|
+
) -> None:
|
|
2340
|
+
package_directory = temporary_git_repository / "pkg"
|
|
2341
|
+
config_directory = package_directory / "config"
|
|
2342
|
+
write_file(package_directory / "__init__.py", "")
|
|
2343
|
+
write_file(config_directory / "__init__.py", "")
|
|
2344
|
+
write_file(
|
|
2345
|
+
config_directory / "pkg_constants.py",
|
|
2346
|
+
'"""Frame constants."""\n\nFRAME_COUNT: int = 9\n',
|
|
2347
|
+
)
|
|
2348
|
+
write_file(
|
|
2349
|
+
package_directory / "consumer.py",
|
|
2350
|
+
'"""Consumer of the frame constants."""\n\n'
|
|
2351
|
+
"from pkg.config.pkg_constants import FRAME_COUNT\n\n"
|
|
2352
|
+
"frame_total = FRAME_COUNT\n",
|
|
2353
|
+
)
|
|
2354
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
2355
|
+
write_file(
|
|
2356
|
+
config_directory / "pkg_constants.py",
|
|
2357
|
+
'"""Frame constants."""\n\nFRAME_COUNT: int = 9\nPLATE_COUNT: int = 4\n',
|
|
2358
|
+
)
|
|
2359
|
+
write_file(
|
|
2360
|
+
package_directory / "consumer.py",
|
|
2361
|
+
'"""Consumer of the frame constants."""\n\n'
|
|
2362
|
+
"from pkg.config.pkg_constants import FRAME_COUNT, PLATE_COUNT\n\n"
|
|
2363
|
+
"frame_total = FRAME_COUNT + PLATE_COUNT\n",
|
|
2364
|
+
)
|
|
2365
|
+
stage_file(temporary_git_repository, "pkg/config/pkg_constants.py")
|
|
2366
|
+
stage_file(temporary_git_repository, "pkg/consumer.py")
|
|
2367
|
+
|
|
2368
|
+
monkeypatch.chdir(tmp_path_factory.mktemp("elsewhere"))
|
|
2369
|
+
exit_code = gate_module.main(
|
|
2370
|
+
["--repo-root", str(temporary_git_repository), "--staged"]
|
|
2371
|
+
)
|
|
2372
|
+
|
|
2373
|
+
assert exit_code == 0
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
def test_paths_from_git_staged_ignores_inherited_git_environment(
|
|
2377
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2378
|
+
) -> None:
|
|
2379
|
+
target_repository = tmp_path / "target_repository"
|
|
2380
|
+
target_repository.mkdir()
|
|
2381
|
+
initialize_git_repository(target_repository)
|
|
2382
|
+
write_file(target_repository / "pkg" / "example_module.py", "EXAMPLE = 1\n")
|
|
2383
|
+
stage_file(target_repository, "pkg/example_module.py")
|
|
2384
|
+
victim_repository = tmp_path / "victim_repository"
|
|
2385
|
+
victim_repository.mkdir()
|
|
2386
|
+
initialize_git_repository(victim_repository)
|
|
2387
|
+
monkeypatch.setenv("GIT_DIR", str(victim_repository / ".git"))
|
|
2388
|
+
monkeypatch.setenv("GIT_WORK_TREE", str(victim_repository))
|
|
2389
|
+
monkeypatch.setenv("GIT_INDEX_FILE", str(victim_repository / ".git" / "index"))
|
|
2390
|
+
|
|
2391
|
+
all_staged_names = [
|
|
2392
|
+
each_path.name
|
|
2393
|
+
for each_path in gate_module.paths_from_git_staged(target_repository)
|
|
2394
|
+
]
|
|
2395
|
+
|
|
2396
|
+
assert all_staged_names == ["example_module.py"]
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
def test_run_staged_test_files_runs_pytest_without_git_environment(
|
|
2400
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2401
|
+
) -> None:
|
|
2402
|
+
repository_root = tmp_path / "repository_under_test"
|
|
2403
|
+
repository_root.mkdir()
|
|
2404
|
+
initialize_git_repository(repository_root)
|
|
2405
|
+
staged_test_body = (
|
|
2406
|
+
"import os\n"
|
|
2407
|
+
"\n"
|
|
2408
|
+
"\n"
|
|
2409
|
+
"def test_environment_carries_no_git_variables() -> None:\n"
|
|
2410
|
+
" all_git_names = sorted(\n"
|
|
2411
|
+
" each_name for each_name in os.environ"
|
|
2412
|
+
" if each_name.startswith('GIT_')\n"
|
|
2413
|
+
" )\n"
|
|
2414
|
+
" assert all_git_names == []\n"
|
|
2415
|
+
)
|
|
2416
|
+
write_file(repository_root / "test_environment_scrub.py", staged_test_body)
|
|
2417
|
+
stage_file(repository_root, "test_environment_scrub.py")
|
|
2418
|
+
monkeypatch.setenv("GIT_AUTHOR_NAME", "Hostile Hook Environment")
|
|
2419
|
+
|
|
2420
|
+
assert gate_module.run_staged_test_files(repository_root) == 0
|