claude-dev-env 1.85.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.
@@ -1,23 +1,22 @@
1
- """Flag a prose term that near-misses a code identifier a diff introduces.
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: two names for one thing, and a reader
5
- who searches one never finds the other.
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 (the identifier)
10
- prose adds: "the premium-request budget" (a hyphenated term)
11
- flag: premium-request vs premium_interactions -- shared first token, tail diverges
12
- ok: premium-interactions -- exact hyphen form, no drift
13
- ok: read-only, test files -- English compound or plural, not drift
14
-
15
- Only hyphenated prose is a candidate, since a hyphen marks a deliberate compound
16
- while a spaced word run is ordinary prose. Markdown scans in full; a JavaScript
17
- or TypeScript line contributes its comments and string literals; a Python file
18
- contributes identifiers only; a test code file is skipped whole. Each near-miss
19
- prints one ``file:line`` finding and the run exits non-zero, so a commit gate
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
- ) -> tuple[frozenset[IdentifierTuple], dict[str, list[IdentifierTuple]]]:
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
- A ``(all_identifier_tuples, identifiers_by_first_token)`` pair. The map
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
- identifiers_by_first_token: dict[str, list[IdentifierTuple]] = {}
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 in full. A JavaScript or TypeScript line contributes
199
- its comment tail, its JSDoc continuation text, and the contents of its string
200
- literals. A Python line contributes no prose: to avoid flagging a module's own
201
- vocabulary as drift, the sweep ignores Python comments, docstrings, and string
202
- literals and scans Python files for identifiers only.
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
- all_fragments.extend(_string_literal_fragments(line_text))
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 but
309
- differing in a later token, or None when the candidate matches an
310
- identifier exactly, shares no prefix, ends in a common English compound
311
- tail word (``read-only``, ``data-driven``), or differs from the
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``) each of which marks the
314
- candidate ordinary prose rather than a near-miss of the identifier.
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, all_identifier_tuples, identifiers_by_first_token
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="_".join(matched_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(diff_text: str) -> list[str]:
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
- each_added_line
383
- for each_added_line in _parse_added_lines(diff_text)
384
- if not _is_test_file(each_added_line[0])
385
- ]
386
- all_identifier_tuples, identifiers_by_first_token = _collect_introduced_identifiers(
387
- all_added_lines
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 all_identifier_tuples:
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
- all_identifier_tuples,
399
- identifiers_by_first_token,
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 _git_environment() -> dict[str, str]:
526
+ def repository_environment() -> dict[str, str]:
406
527
  """Return the process environment with every GIT_ variable removed.
407
528
 
408
- A git commit hook runs this sweep with GIT_INDEX_FILE pointed at the commit
409
- index. Stripping every GIT_ variable makes ``git diff --cached`` read the
410
- repository's own index, so the sweep sees the staged diff of the repository
411
- it was handed rather than an ambient override.
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=_git_environment(),
630
+ env=repository_environment(),
440
631
  )
441
632
  if diff_process.returncode != 0:
442
633
  return []
443
- return sweep_diff(diff_process.stdout)
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: