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.
Files changed (50) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
  4. package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
  5. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
  6. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
  7. package/hooks/blocking/CLAUDE.md +1 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  9. package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
  10. package/hooks/blocking/code_rules_docstrings.py +17 -13
  11. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  12. package/hooks/blocking/code_rules_naming_collection.py +5 -3
  13. package/hooks/blocking/code_rules_paired_test.py +3 -2
  14. package/hooks/blocking/code_rules_string_magic.py +1 -1
  15. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  16. package/hooks/blocking/session_edit_stage_gate.py +654 -0
  17. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  18. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  19. package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
  20. package/hooks/hooks.json +30 -0
  21. package/hooks/hooks_constants/CLAUDE.md +2 -0
  22. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  23. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
  24. package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
  25. package/hooks/lifecycle/CLAUDE.md +1 -1
  26. package/hooks/observability/CLAUDE.md +2 -0
  27. package/hooks/observability/session_file_edit_tracker.py +224 -0
  28. package/hooks/observability/test_session_file_edit_tracker.py +174 -0
  29. package/hooks/session/CLAUDE.md +6 -2
  30. package/hooks/session/session_edit_tracker_cleanup.py +120 -0
  31. package/hooks/session/task_list_loop_starter.py +36 -0
  32. package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
  33. package/hooks/session/test_task_list_loop_starter.py +53 -0
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/docstring-prose-matches-implementation.md +1 -1
  37. package/rules/env-var-table-code-drift.md +1 -1
  38. package/rules/package-inventory-stale-entry.md +1 -1
  39. package/rules/paired-test-coverage.md +1 -1
  40. package/rules/re-stage-before-commit.md +31 -0
  41. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  42. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  43. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  44. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  45. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  46. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  47. package/skills/autoconverge/SKILL.md +46 -3
  48. package/skills/autoconverge/workflow/converge.mjs +1 -1
  49. package/skills/pr-converge/SKILL.md +27 -1
  50. package/skills/pr-converge/reference/state-schema.md +12 -0
@@ -1,40 +1,26 @@
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 hyphenated term
11
- 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
- Only hyphenated terms are candidates. A hyphen marks a deliberate compound
16
- the author bound the words into one term so a divergent tail there is a real
17
- naming drift. A bare spaced word run is ordinary prose: windowing every
18
- sentence that starts with an identifier's first word ("each attempt", "file
19
- exists") floods a vocabulary-dense change with false findings and gives the
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
- ) -> tuple[frozenset[IdentifierTuple], dict[str, list[IdentifierTuple]]]:
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
- A ``(all_identifier_tuples, identifiers_by_first_token)`` pair. The map
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
- identifiers_by_first_token: dict[str, list[IdentifierTuple]] = {}
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 in full. A code line contributes its comment tail,
179
- its JSDoc continuation text, and the contents of its string literals.
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
- if _file_extension(file_path) == MARKDOWN_FILE_EXTENSION:
189
- return [line_text]
190
- if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
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
- 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))
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 but
283
- differing in a later token, or None when the candidate matches an
284
- identifier exactly, shares no prefix, ends in a common English compound
285
- 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
286
400
  identifier only by a singular/plural form of one or more tokens
287
- (``test files`` against ``test_file``) each of which marks the
288
- 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).
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, all_identifier_tuples, identifiers_by_first_token
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="_".join(matched_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(diff_text: str) -> list[str]:
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, identifiers_by_first_token = _collect_introduced_identifiers(
357
- all_added_lines
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 all_identifier_tuples:
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
- all_identifier_tuples,
369
- identifiers_by_first_token,
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
- 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)
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