claude-dev-env 8.6.0 → 8.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,10 @@ MARKDOWN_FILE_EXTENSION: str = ".md"
10
10
 
11
11
  INLINE_CODE_SPAN_PATTERN: re.Pattern[str] = re.compile(r"`[^`]*`")
12
12
 
13
+ MARKDOWN_LINK_TARGET_PATTERN: re.Pattern[str] = re.compile(r"\]\([^)]*\)")
14
+
15
+ SENTENCE_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"(?<=[.!?])\s+")
16
+
13
17
  SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
14
18
  r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
15
19
  )
@@ -62,10 +62,12 @@ from pr_loop_shared_constants.terminology_sweep_constants import (
62
62
  JAVASCRIPT_LINE_COMMENT_MARKER,
63
63
  JSDOC_CONTINUATION_MARKER,
64
64
  MARKDOWN_FILE_EXTENSION,
65
+ MARKDOWN_LINK_TARGET_PATTERN,
65
66
  MINIMUM_IDENTIFIER_TOKEN_COUNT,
66
67
  PROSE_WINDOW_WORD_SEPARATOR,
67
68
  PROSE_WORD_PATTERN,
68
69
  PYTHON_COMMENT_MARKER,
70
+ SENTENCE_BOUNDARY_PATTERN,
69
71
  SNAKE_CASE_IDENTIFIER_PATTERN,
70
72
  STRING_LITERAL_CONTENT_PATTERN,
71
73
  TERMINOLOGY_FINDING_TEMPLATE,
@@ -74,7 +76,6 @@ from pr_loop_shared_constants.terminology_sweep_constants import (
74
76
  TEST_FILE_PREFIX,
75
77
  TEST_FILE_SUFFIX,
76
78
  )
77
-
78
79
  from subprocess_window_access import hidden_window_creation_flags
79
80
 
80
81
  IdentifierTuple = tuple[str, ...]
@@ -161,27 +162,78 @@ def _collect_introduced_identifiers(
161
162
  ) -> frozenset[IdentifierTuple]:
162
163
  """Return the identifier tuples introduced on added code lines.
163
164
 
165
+ A name bound only inside a test module is a fixture's own local variable,
166
+ not a term the rest of the repository is expected to agree with, so a
167
+ test file's identifiers are left out.
168
+
164
169
  Args:
165
170
  all_added_lines: Added-line triples from :func:`_parse_added_lines`.
166
171
 
167
172
  Returns:
168
- Every multi-word identifier tuple appearing on an added code line.
173
+ Every multi-word identifier tuple appearing on an added,
174
+ non-test-module code line.
169
175
  """
170
176
  all_identifier_tuples: set[IdentifierTuple] = set()
171
177
  for each_file_path, _, each_text in all_added_lines:
172
- if _file_extension(each_file_path) in ALL_SWEEP_CODE_FILE_EXTENSIONS:
178
+ if _file_extension(
179
+ each_file_path
180
+ ) in ALL_SWEEP_CODE_FILE_EXTENSIONS and not _is_test_file(each_file_path):
173
181
  all_identifier_tuples.update(_identifier_tuples_in_text(each_text))
174
182
  return frozenset(all_identifier_tuples)
175
183
 
176
184
 
185
+ def _fragments_split_at_breaks(all_fragments: list[str]) -> list[str]:
186
+ """Split each fragment at every sentence, link, and code-span break in turn."""
187
+ for each_break_pattern in (
188
+ INLINE_CODE_SPAN_PATTERN,
189
+ MARKDOWN_LINK_TARGET_PATTERN,
190
+ SENTENCE_BOUNDARY_PATTERN,
191
+ ):
192
+ all_fragments = [
193
+ each_piece
194
+ for each_fragment in all_fragments
195
+ for each_piece in each_break_pattern.split(each_fragment)
196
+ ]
197
+ return all_fragments
198
+
199
+
200
+ def _split_into_prose_fragments(raw_fragment: str) -> list[str]:
201
+ """Split a raw prose fragment at every sentence, link, and code-span break.
202
+
203
+ ::
204
+
205
+ raw: "…gates the pull request. [`link`](docs/README.md) explains it."
206
+ split -> ["…gates the pull request", "explains it."]
207
+ flag: a window built from the joined text -- "pull request docs"
208
+ ok: "pull request" and "docs" now sit in different fragments
209
+
210
+ A sentence-ending period, a Markdown link's target, and a backticked code
211
+ span each end whatever the reader was reading and start something new.
212
+ Splitting there keeps a word window scoped to one sentence, clear of a
213
+ stripped span's unrelated text on its far side.
214
+
215
+ Args:
216
+ raw_fragment: One prose fragment collected from an added line.
217
+
218
+ Returns:
219
+ The fragment's pieces with every break removed, in order, dropping
220
+ any piece left blank.
221
+ """
222
+ all_fragments = _fragments_split_at_breaks([raw_fragment])
223
+ return [
224
+ each_fragment.strip() for each_fragment in all_fragments if each_fragment.strip()
225
+ ]
226
+
227
+
177
228
  def _prose_fragments(file_path: str, line_text: str) -> list[str]:
178
229
  """Return the prose fragments of an added line worth scanning for terms.
179
230
 
180
- A Markdown line is prose with its inline-code spans removed, since a
181
- backticked span names code verbatim. A code line contributes its comment
182
- tail, its JSDoc continuation text, and the contents of its string
183
- literals. A test module contributes its comment tail and JSDoc text only.
184
- Its string literals hold fixture data, not prose.
231
+ A Markdown line is prose split at its sentence, link, and code-span
232
+ breaks, since a backticked span names code verbatim and a link target is
233
+ a path, not a word. A code line contributes its comment tail, its JSDoc
234
+ continuation text, and the contents of its string literals, each split
235
+ the same way. A test module contributes its comment tail and JSDoc text
236
+ only. Its string literals hold fixture data, not prose.
185
237
 
186
238
  Args:
187
239
  file_path: The path the added line belongs to.
@@ -192,17 +244,21 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
192
244
  """
193
245
  extension = _file_extension(file_path)
194
246
  if extension == MARKDOWN_FILE_EXTENSION:
195
- return [INLINE_CODE_SPAN_PATTERN.sub(" ", line_text)]
247
+ return _split_into_prose_fragments(line_text)
196
248
  if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
197
249
  return []
198
- all_fragments: list[str] = []
250
+ all_raw_fragments: list[str] = []
199
251
  stripped_line = line_text.strip()
200
252
  if stripped_line.startswith(JSDOC_CONTINUATION_MARKER):
201
- all_fragments.append(stripped_line)
202
- all_fragments.extend(_comment_fragments(line_text))
253
+ all_raw_fragments.append(stripped_line)
254
+ all_raw_fragments.extend(_comment_fragments(line_text))
203
255
  if not _is_test_file(file_path):
204
- all_fragments.extend(_string_literal_fragments(line_text))
205
- return all_fragments
256
+ all_raw_fragments.extend(_string_literal_fragments(line_text))
257
+ return [
258
+ each_split_fragment
259
+ for each_raw_fragment in all_raw_fragments
260
+ for each_split_fragment in _split_into_prose_fragments(each_raw_fragment)
261
+ ]
206
262
 
207
263
 
208
264
  def _is_test_file(file_path: str) -> bool:
@@ -428,15 +484,30 @@ def _near_miss_identifier(
428
484
  all_identifier_tuples: frozenset[IdentifierTuple],
429
485
  identifiers_by_first_token: dict[str, list[IdentifierTuple]],
430
486
  all_identifier_tokens: frozenset[str],
487
+ all_known_identifier_tuples: frozenset[IdentifierTuple],
431
488
  ) -> IdentifierTuple | None:
432
489
  """Return an identifier the candidate near-misses, or None when it does not.
433
490
 
491
+ ::
492
+
493
+ known identifier: cde_lint (scripts/cde_lint.py, the tool's own name)
494
+ introduced: cde_lint_path 3-token, adds "path" to that name
495
+ candidate: "cde lint standard" shares "cde lint", then "standard"
496
+ candidate[:-1]: ("cde", "lint") == the known identifier cde_lint
497
+ ok: candidate[:-1] names cde_lint verbatim -- agreement, not a near-miss
498
+ flag: "premium request budget" against premium_request_interactions
499
+ -- "premium request" names no identifier of its own
500
+
434
501
  Args:
435
502
  candidate_tuple: The prose term's lowercase token tuple.
436
503
  all_identifier_tuples: Every introduced identifier tuple.
437
504
  identifiers_by_first_token: Identifier tuples grouped by leading token.
438
505
  all_identifier_tokens: Every token of every identifier on added code
439
506
  lines, used to spare prose built from real code vocabulary.
507
+ all_known_identifier_tuples: Every identifier tuple the sweep knows
508
+ about, introduced or pre-existing, plus each added file's stem,
509
+ used to spare a candidate whose leading tokens name one of them
510
+ verbatim.
440
511
 
441
512
  Returns:
442
513
  The first identifier the candidate renames in only its final token. Such a
@@ -449,9 +520,12 @@ def _near_miss_identifier(
449
520
  common English compound tail word (``read-only``, ``data-driven``); it
450
521
  contains an English stopword (``to a``, ``each image``); it differs
451
522
  from the identifier only by a singular/plural form of one or more
452
- tokens (``test files`` against ``test_file``); or every diverging word
523
+ tokens (``test files`` against ``test_file``); every diverging word
453
524
  is itself a token of some introduced identifier (``target width box``
454
- when ``box_height`` is also in the diff).
525
+ when ``box_height`` is also in the diff); or its leading tokens, with
526
+ the final diverging word set aside, spell out a known identifier of
527
+ their own (``cde_lint standard`` against ``cde_lint_path``, when
528
+ ``cde_lint`` already names something in the tree).
455
529
  """
456
530
  if not candidate_tuple or candidate_tuple in all_identifier_tuples:
457
531
  return None
@@ -468,6 +542,8 @@ def _near_miss_identifier(
468
542
  continue
469
543
  if not _diverges_only_in_final_token(candidate_tuple, each_identifier):
470
544
  continue
545
+ if candidate_tuple[:-1] in all_known_identifier_tuples:
546
+ continue
471
547
  diverging_words = [
472
548
  each_word
473
549
  for each_word, each_token in zip(candidate_tuple, each_identifier)
@@ -489,6 +565,7 @@ def _findings_for_line(
489
565
  all_identifier_tuples: frozenset[IdentifierTuple],
490
566
  identifiers_by_first_token: dict[str, list[IdentifierTuple]],
491
567
  all_identifier_tokens: frozenset[str],
568
+ all_known_identifier_tuples: frozenset[IdentifierTuple],
492
569
  ) -> list[str]:
493
570
  """Return the near-miss findings for one added line.
494
571
 
@@ -500,6 +577,8 @@ def _findings_for_line(
500
577
  identifiers_by_first_token: Identifier tuples grouped by leading token.
501
578
  all_identifier_tokens: Every token of every identifier on added code
502
579
  lines, used to spare prose built from real code vocabulary.
580
+ all_known_identifier_tuples: Every identifier tuple the sweep knows
581
+ about, passed through to :func:`_near_miss_identifier`.
503
582
 
504
583
  Returns:
505
584
  One finding string per distinct near-miss term on the line.
@@ -515,6 +594,7 @@ def _findings_for_line(
515
594
  all_identifier_tuples,
516
595
  identifiers_by_first_token,
517
596
  all_identifier_tokens,
597
+ all_known_identifier_tuples,
518
598
  )
519
599
  if matched_identifier is None or each_tuple in reported_tuples:
520
600
  continue
@@ -530,6 +610,50 @@ def _findings_for_line(
530
610
  return all_findings
531
611
 
532
612
 
613
+ def _stem_identifier_tuple_for_code_file(each_file_path: str) -> IdentifierTuple | None:
614
+ """Return a non-test code file's stem as a multi-word identifier tuple.
615
+
616
+ ``None`` when the path is not a swept code file, is a test file, or its
617
+ stem carries fewer than the minimum identifier token count.
618
+ """
619
+ if _file_extension(each_file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS or _is_test_file(
620
+ each_file_path
621
+ ):
622
+ return None
623
+ stem_tuple = _identifier_token_tuple(Path(each_file_path).stem)
624
+ if len(stem_tuple) < MINIMUM_IDENTIFIER_TOKEN_COUNT:
625
+ return None
626
+ return stem_tuple
627
+
628
+
629
+ def _file_stem_identifier_tuples(
630
+ all_added_lines: list[tuple[str, int, str]],
631
+ ) -> frozenset[IdentifierTuple]:
632
+ """Return the identifier tuple of each added, non-test code file's stem.
633
+
634
+ ::
635
+
636
+ touched file: scripts/cde_lint_partition.py
637
+ stem tuple: ("cde", "lint", "partition")
638
+
639
+ A file that exists in the tree names something whether or not that name
640
+ is ever spelled out as a code identifier, so a file's stem joins the
641
+ known-identifier vocabulary the same way a spelled-out name does.
642
+
643
+ Args:
644
+ all_added_lines: Added-line triples from :func:`_parse_added_lines`.
645
+
646
+ Returns:
647
+ One tuple per distinct, multi-word stem of an added, non-test code
648
+ file.
649
+ """
650
+ return frozenset(
651
+ stem_tuple
652
+ for each_file_path, _, _ in all_added_lines
653
+ if (stem_tuple := _stem_identifier_tuple_for_code_file(each_file_path)) is not None
654
+ )
655
+
656
+
533
657
  def _find_terminology_near_misses(
534
658
  diff_text: str, all_preexisting_identifier_tuples: frozenset[IdentifierTuple]) -> list[str]:
535
659
  all_added_lines = _parse_added_lines(diff_text)
@@ -537,6 +661,9 @@ def _find_terminology_near_misses(
537
661
  all_identifier_tokens = frozenset(
538
662
  each_token for each_tuple in all_identifier_tuples for each_token in each_tuple
539
663
  )
664
+ all_known_identifier_tuples = all_identifier_tuples | _file_stem_identifier_tuples(
665
+ all_added_lines
666
+ )
540
667
  introduced_tuples = frozenset(
541
668
  each_tuple
542
669
  for each_tuple in all_identifier_tuples
@@ -557,6 +684,7 @@ def _find_terminology_near_misses(
557
684
  introduced_tuples,
558
685
  introduced_by_first_token,
559
686
  all_identifier_tokens,
687
+ all_known_identifier_tuples,
560
688
  )
561
689
  )
562
690
  return all_findings
@@ -605,15 +733,23 @@ def repository_environment() -> dict[str, str]:
605
733
  def _identifier_names_on_added_code_lines(diff_text: str) -> frozenset[str]:
606
734
  """Return every multi-word identifier name on the diff's added code lines.
607
735
 
736
+ A name bound only inside a test module is left out, matching
737
+ :func:`_collect_introduced_identifiers`, since it is never treated as
738
+ introduced terminology and its presence in the base tree is not worth a
739
+ lookup.
740
+
608
741
  Args:
609
742
  diff_text: The unified-diff text to scan.
610
743
 
611
744
  Returns:
612
- The distinct snake_case and camelCase names of two or more tokens.
745
+ The distinct snake_case and camelCase names of two or more tokens
746
+ on a non-test-module code line.
613
747
  """
614
748
  all_names: set[str] = set()
615
749
  for each_file_path, _, each_text in _parse_added_lines(diff_text):
616
- if _file_extension(each_file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
750
+ if _file_extension(
751
+ each_file_path
752
+ ) not in ALL_SWEEP_CODE_FILE_EXTENSIONS or _is_test_file(each_file_path):
617
753
  continue
618
754
  all_found_names = SNAKE_CASE_IDENTIFIER_PATTERN.findall(each_text)
619
755
  all_found_names += CAMEL_CASE_IDENTIFIER_PATTERN.findall(each_text)
@@ -626,6 +626,123 @@ def test_does_not_flag_two_token_identifier_first_word_coincidence() -> None:
626
626
  assert sweep_diff(diff) == []
627
627
 
628
628
 
629
+ def test_does_not_flag_window_crossing_a_sentence_boundary() -> None:
630
+ diff = (
631
+ "diff --git a/api/quota.py b/api/quota.py\n"
632
+ "--- a/api/quota.py\n"
633
+ "+++ a/api/quota.py\n"
634
+ "@@ -0,0 +1,1 @@\n"
635
+ "+pull_request_owner = 5\n"
636
+ "diff --git a/README.md b/README.md\n"
637
+ "--- a/README.md\n"
638
+ "+++ b/README.md\n"
639
+ "@@ -0,0 +1,1 @@\n"
640
+ "+Merge the pull request. Trigger warnings surface elsewhere.\n"
641
+ )
642
+ assert sweep_diff(diff) == []
643
+
644
+
645
+ def test_does_not_flag_window_crossing_a_markdown_link_target() -> None:
646
+ diff = (
647
+ "diff --git a/api/quota.py b/api/quota.py\n"
648
+ "--- a/api/quota.py\n"
649
+ "+++ a/api/quota.py\n"
650
+ "@@ -0,0 +1,1 @@\n"
651
+ "+pull_request_owner = 5\n"
652
+ "diff --git a/README.md b/README.md\n"
653
+ "--- a/README.md\n"
654
+ "+++ b/README.md\n"
655
+ "@@ -0,0 +1,1 @@\n"
656
+ "+See the pull request [`link`](docs/README.md) explains it.\n"
657
+ )
658
+ assert sweep_diff(diff) == []
659
+
660
+
661
+ def test_does_not_flag_identifier_bound_only_in_a_test_module() -> None:
662
+ diff = (
663
+ "diff --git a/tests/test_config.py b/tests/test_config.py\n"
664
+ "--- a/tests/test_config.py\n"
665
+ "+++ b/tests/test_config.py\n"
666
+ "@@ -0,0 +1,1 @@\n"
667
+ "+jon_pc_json = load_fixture()\n"
668
+ "diff --git a/README.md b/README.md\n"
669
+ "--- a/README.md\n"
670
+ "+++ b/README.md\n"
671
+ "@@ -0,0 +1,1 @@\n"
672
+ "+The jon pc export runs first.\n"
673
+ )
674
+ assert sweep_diff(diff) == []
675
+
676
+
677
+ def test_still_flags_identifier_bound_in_a_production_module() -> None:
678
+ diff = (
679
+ "diff --git a/api/config.py b/api/config.py\n"
680
+ "--- a/api/config.py\n"
681
+ "+++ b/api/config.py\n"
682
+ "@@ -0,0 +1,1 @@\n"
683
+ "+jon_pc_json = load_config()\n"
684
+ "diff --git a/README.md b/README.md\n"
685
+ "--- a/README.md\n"
686
+ "+++ b/README.md\n"
687
+ "@@ -0,0 +1,1 @@\n"
688
+ "+The jon pc export runs first.\n"
689
+ )
690
+ findings = sweep_diff(diff)
691
+ assert len(findings) == 1
692
+ assert "jon pc export" in findings[0]
693
+
694
+
695
+ def test_does_not_flag_window_whose_leading_tokens_are_a_known_identifier() -> None:
696
+ diff = (
697
+ "diff --git a/api/store.py b/api/store.py\n"
698
+ "--- a/api/store.py\n"
699
+ "+++ b/api/store.py\n"
700
+ "@@ -0,0 +1,2 @@\n"
701
+ "+cde_lint_path = Path(value)\n"
702
+ '+HELP_TEXT = "cde_lint standard output"\n'
703
+ )
704
+ assert sweep_diff(diff) == []
705
+
706
+
707
+ def test_still_flags_window_whose_leading_tokens_name_nothing_known() -> None:
708
+ diff = (
709
+ "diff --git a/api/quota.py b/api/quota.py\n"
710
+ "--- a/api/quota.py\n"
711
+ "+++ b/api/quota.py\n"
712
+ "@@ -0,0 +1,1 @@\n"
713
+ "+premium_request_interactions = 5\n"
714
+ "diff --git a/docs/README.md b/docs/README.md\n"
715
+ "--- a/docs/README.md\n"
716
+ "+++ b/docs/README.md\n"
717
+ "@@ -0,0 +1,1 @@\n"
718
+ "+The premium request budget gates the run.\n"
719
+ )
720
+ findings = sweep_diff(diff)
721
+ assert len(findings) == 1
722
+ assert "premium request budget" in findings[0]
723
+
724
+
725
+ def test_does_not_flag_window_whose_leading_tokens_are_an_added_files_stem() -> None:
726
+ diff = (
727
+ "diff --git a/scripts/cde_lint.py b/scripts/cde_lint.py\n"
728
+ "--- a/scripts/cde_lint.py\n"
729
+ "+++ b/scripts/cde_lint.py\n"
730
+ "@@ -0,0 +1,1 @@\n"
731
+ "+VERSION = 1\n"
732
+ "diff --git a/scripts/cde_lint_partition.py b/scripts/cde_lint_partition.py\n"
733
+ "--- a/scripts/cde_lint_partition.py\n"
734
+ "+++ b/scripts/cde_lint_partition.py\n"
735
+ "@@ -0,0 +1,1 @@\n"
736
+ "+cde_lint_owner = 5\n"
737
+ "diff --git a/docs/README.md b/docs/README.md\n"
738
+ "--- a/docs/README.md\n"
739
+ "+++ b/docs/README.md\n"
740
+ "@@ -0,0 +1,1 @@\n"
741
+ "+Run cde_lint standard checks first.\n"
742
+ )
743
+ assert sweep_diff(diff) == []
744
+
745
+
629
746
  def test_flags_near_miss_when_second_leading_token_is_plural_variant() -> None:
630
747
  diff = (
631
748
  "diff --git a/api/store.py b/api/store.py\n"
@@ -13,6 +13,7 @@ import json
13
13
  import os
14
14
  import sys
15
15
  from pathlib import Path
16
+ from re import Pattern
16
17
  from typing import TextIO
17
18
 
18
19
  try:
@@ -33,10 +34,13 @@ try:
33
34
  ALL_HASH_AND_SLASH_EXTENSIONS,
34
35
  ALL_HASH_ONLY_EXTENSIONS,
35
36
  ALL_MARKDOWN_EXTENSIONS,
36
- CODE_FENCE_PATTERN,
37
+ BLOCK_COMMENT_CLOSE_MARKER,
38
+ BLOCK_COMMENT_OPEN_MARKER,
39
+ DOUBLE_QUOTE_BODY_GROUP,
37
40
  DOUBLE_QUOTED_SPAN_PATTERN,
38
41
  INLINE_CODE_PATTERN,
39
42
  PYTHON_EXTENSION,
43
+ SINGLE_QUOTE_BODY_GROUP,
40
44
  TRIPLE_QUOTED_BLOCK_PATTERN,
41
45
  )
42
46
  except ImportError as import_error:
@@ -77,15 +81,56 @@ def _get_inline_markers(extension: str) -> tuple[str, ...]:
77
81
  return ("//",)
78
82
 
79
83
 
80
- def _extract_comment_lines(text: str, extension: str = "") -> list[str]:
81
- """Extract comment lines from source code — Python (#), JS/TS/C/Rust/Go (//), and block comments."""
82
- all_comment_lines: list[str] = []
83
- all_lines = text.splitlines()
84
+ def _consume_block_comment_open(
85
+ each_line_number: int, stripped: str
86
+ ) -> tuple[list[tuple[int, str]], str | None, bool]:
87
+ """Extract the comment span a ``/*``-opening line carries.
88
+
89
+ Returns the extracted entries, the leftover code text to keep scanning
90
+ on this same line (``None`` when the whole line was consumed), and
91
+ whether the comment is still open past this line.
92
+ """
93
+ slash_star_index = stripped.find(BLOCK_COMMENT_OPEN_MARKER)
94
+ close_star_index = stripped.find(
95
+ BLOCK_COMMENT_CLOSE_MARKER, slash_star_index + len(BLOCK_COMMENT_OPEN_MARKER)
96
+ )
97
+ if close_star_index < 0:
98
+ return [(each_line_number, stripped[slash_star_index:])], None, True
99
+ close_end = close_star_index + len(BLOCK_COMMENT_CLOSE_MARKER)
100
+ entry = (each_line_number, stripped[slash_star_index:close_end])
101
+ after_close = stripped[close_end:].lstrip()
102
+ return [entry], (after_close or None), False
103
+
104
+
105
+ def _consume_block_comment_continuation(
106
+ each_line_number: int, stripped: str
107
+ ) -> tuple[list[tuple[int, str]], bool, bool]:
108
+ """Extract the comment span a line inside an already-open block carries.
109
+
110
+ Returns the extracted entries, whether the comment is still open past
111
+ this line, and whether the caller should move to the next source line.
112
+ """
113
+ close_index = stripped.find(BLOCK_COMMENT_CLOSE_MARKER)
114
+ if close_index < 0:
115
+ return [(each_line_number, stripped)], True, True
116
+ close_end = close_index + len(BLOCK_COMMENT_CLOSE_MARKER)
117
+ return [(each_line_number, stripped[:close_end])], False, False
118
+
119
+
120
+ def _extract_comment_lines(text: str, extension: str = "") -> list[tuple[int, str]]:
121
+ """Return each comment line as ``(source_line_number, comment_text)``.
122
+
123
+ Covers Python (``#``), JS/TS/C/Rust/Go (``//``), and block comments, and
124
+ tags every extracted line with the source line it came from so a
125
+ matching pattern can be pinned to that line rather than searched for
126
+ again.
127
+ """
128
+ all_comment_lines: list[tuple[int, str]] = []
84
129
 
85
130
  is_in_block_comment = False
86
131
  has_block_comments = extension in ALL_BLOCK_COMMENT_EXTENSIONS
87
132
  all_inline_markers = _get_inline_markers(extension)
88
- for each_line in all_lines:
133
+ for each_line_number, each_line in enumerate(text.splitlines(), 1):
89
134
  stripped = each_line.strip()
90
135
 
91
136
  if has_block_comments:
@@ -93,42 +138,33 @@ def _extract_comment_lines(text: str, extension: str = "") -> list[str]:
93
138
  stripped.startswith(each_marker)
94
139
  for each_marker in all_inline_markers
95
140
  ):
96
- all_comment_lines.append(stripped)
141
+ all_comment_lines.append((each_line_number, stripped))
97
142
  continue
98
- if "/*" in stripped and not is_in_block_comment:
99
- is_in_block_comment = True
100
- slash_star_index = stripped.find("/*")
101
- close_star_index = stripped.find("*/", slash_star_index + len("/*"))
102
- if close_star_index >= 0:
103
- all_comment_lines.append(
104
- stripped[slash_star_index : close_star_index + 2]
105
- )
106
- is_in_block_comment = False
107
- after_close = stripped[close_star_index + 2:].lstrip()
108
- if not after_close:
109
- continue
110
- stripped = after_close
111
- else:
112
- all_comment_lines.append(stripped[slash_star_index:])
143
+ if BLOCK_COMMENT_OPEN_MARKER in stripped and not is_in_block_comment:
144
+ entries, leftover, is_in_block_comment = _consume_block_comment_open(
145
+ each_line_number, stripped
146
+ )
147
+ all_comment_lines.extend(entries)
148
+ if leftover is None:
113
149
  continue
150
+ stripped = leftover
114
151
  if is_in_block_comment:
115
- close_index = stripped.find("*/")
116
- if close_index >= 0:
117
- all_comment_lines.append(stripped[: close_index + 2])
118
- is_in_block_comment = False
119
- else:
120
- all_comment_lines.append(stripped)
152
+ entries, is_in_block_comment, should_continue = (
153
+ _consume_block_comment_continuation(each_line_number, stripped)
154
+ )
155
+ all_comment_lines.extend(entries)
156
+ if should_continue:
121
157
  continue
122
158
 
123
159
  if any(
124
160
  stripped.startswith(each_marker) for each_marker in all_inline_markers
125
161
  ):
126
- all_comment_lines.append(stripped)
162
+ all_comment_lines.append((each_line_number, stripped))
127
163
  continue
128
164
 
129
165
  inline_index = _find_inline_comment_start(stripped, all_inline_markers)
130
166
  if inline_index is not None and inline_index > 0:
131
- all_comment_lines.append(stripped[inline_index:])
167
+ all_comment_lines.append((each_line_number, stripped[inline_index:]))
132
168
  continue
133
169
 
134
170
  return all_comment_lines
@@ -154,16 +190,18 @@ def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> i
154
190
  return best_position
155
191
 
156
192
 
157
- def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
158
- """Collect the module, class, and function docstrings from a parsed tree.
193
+ def _extract_parsed_docstrings(tree: ast.Module) -> list[tuple[int, str]]:
194
+ """Return each docstring as ``(start_line, docstring_text)``.
159
195
 
160
196
  Args:
161
197
  tree: The parsed module tree to walk.
162
198
 
163
199
  Returns:
164
- Every docstring found on the module and its class/function definitions.
200
+ One entry per docstring found on the module and its class/function
201
+ definitions, tagged with the source line the docstring's opening
202
+ quotes start on.
165
203
  """
166
- all_found_docstrings: list[str] = []
204
+ all_found_docstrings: list[tuple[int, str]] = []
167
205
  for each_node in ast.walk(tree):
168
206
  if not isinstance(
169
207
  each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
@@ -171,102 +209,204 @@ def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
171
209
  continue
172
210
  maybe_docstring = ast.get_docstring(each_node, clean=False)
173
211
  if maybe_docstring:
174
- all_found_docstrings.append(maybe_docstring)
212
+ docstring_statement = each_node.body[0]
213
+ all_found_docstrings.append((docstring_statement.lineno, maybe_docstring))
175
214
  return all_found_docstrings
176
215
 
177
216
 
178
- def _extract_fragment_docstrings(text: str) -> list[str]:
179
- """Collect docstring-positioned triple-quoted blocks from an unparseable fragment.
217
+ def _is_docstring_position(leading_text: str) -> bool:
218
+ """Decide whether the text before a triple-quoted block marks a docstring slot.
180
219
 
181
220
  A triple-quoted block counts as a docstring when it opens the fragment
182
- (only whitespace before it) or when the nearest preceding non-blank line is
183
- a def/class header ending in a colon. A block assigned to a name or sitting
184
- elsewhere in the fragment is data and is skipped.
221
+ (only whitespace before it) or when the nearest preceding non-blank line
222
+ is a def/class header ending in a colon. A block assigned to a name or
223
+ sitting elsewhere in the fragment is data.
224
+ """
225
+ if not leading_text.strip():
226
+ return True
227
+ if leading_text.rpartition("\n")[2].strip():
228
+ return False
229
+ all_preceding_lines = [
230
+ each_line for each_line in leading_text.splitlines() if each_line.strip()
231
+ ]
232
+ if not all_preceding_lines:
233
+ return False
234
+ last_preceding_line = all_preceding_lines[-1].strip()
235
+ return last_preceding_line.startswith(
236
+ ALL_DEFINITION_HEADER_PREFIXES
237
+ ) and last_preceding_line.endswith(":")
238
+
239
+
240
+ def _extract_fragment_docstrings(text: str) -> list[tuple[int, str]]:
241
+ """Return docstring-positioned triple-quoted blocks from an unparseable fragment.
185
242
 
186
243
  Args:
187
244
  text: The Python source fragment that failed to parse.
188
245
 
189
246
  Returns:
190
- The body text of each docstring-positioned triple-quoted block.
247
+ One ``(start_line, body_text)`` pair per docstring-positioned
248
+ triple-quoted block, tagged with the source line its opening quotes
249
+ start on.
191
250
  """
192
- all_found_docstrings: list[str] = []
251
+ all_found_docstrings: list[tuple[int, str]] = []
193
252
  for each_match in TRIPLE_QUOTED_BLOCK_PATTERN.finditer(text):
194
- body = each_match.group(1) if each_match.group(1) is not None else each_match.group(2)
253
+ body = (
254
+ each_match.group(DOUBLE_QUOTE_BODY_GROUP)
255
+ if each_match.group(DOUBLE_QUOTE_BODY_GROUP) is not None
256
+ else each_match.group(SINGLE_QUOTE_BODY_GROUP)
257
+ )
258
+ start_line = text.count("\n", 0, each_match.start()) + 1
195
259
  leading_text = text[: each_match.start()]
196
- if not leading_text.strip():
197
- all_found_docstrings.append(body)
198
- continue
199
- if leading_text.rpartition("\n")[2].strip():
200
- continue
201
- all_preceding_lines = [
202
- each_line for each_line in leading_text.splitlines() if each_line.strip()
203
- ]
204
- last_preceding_line = all_preceding_lines[-1].strip()
205
- if last_preceding_line.startswith(
206
- ALL_DEFINITION_HEADER_PREFIXES
207
- ) and last_preceding_line.endswith(":"):
208
- all_found_docstrings.append(body)
260
+ if _is_docstring_position(leading_text):
261
+ all_found_docstrings.append((start_line, body))
209
262
  return all_found_docstrings
210
263
 
211
264
 
212
- def _extract_python_docstring_text(text: str) -> str:
213
- """Return the scannable docstring prose from Python source.
265
+ def _docstring_scan_lines(text: str) -> list[tuple[int, str]]:
266
+ """Return each docstring's prose as ``(source_line_number, line_text)`` pairs.
214
267
 
215
268
  Parses the source when valid, or falls back to the docstring-positioned
216
269
  triple-quoted blocks of a mid-edit fragment. Double-quoted and backticked
217
270
  spans inside each docstring are mentions rather than uses, so both are
218
- stripped from the returned prose.
271
+ stripped before the docstring is split back into its own source lines.
219
272
 
220
273
  Args:
221
274
  text: The Python source or fragment under scan.
222
275
 
223
276
  Returns:
224
- The docstring prose joined into one scannable string.
277
+ One entry per physical line of docstring prose, numbered against the
278
+ source file so a pattern match on that line carries the line the
279
+ rule matched rather than an unrelated occurrence elsewhere in the
280
+ file.
225
281
  """
226
282
  try:
227
283
  all_found_docstrings = _extract_parsed_docstrings(ast.parse(text))
228
284
  except (SyntaxError, ValueError):
229
285
  all_found_docstrings = _extract_fragment_docstrings(text)
230
- all_docstring_prose = [
231
- DOUBLE_QUOTED_SPAN_PATTERN.sub("", INLINE_CODE_PATTERN.sub("", each_docstring))
232
- for each_docstring in all_found_docstrings
233
- ]
234
- return "\n".join(all_docstring_prose)
286
+ all_scan_lines: list[tuple[int, str]] = []
287
+ for each_start_line, each_docstring in all_found_docstrings:
288
+ prose = DOUBLE_QUOTED_SPAN_PATTERN.sub(
289
+ "", INLINE_CODE_PATTERN.sub("", each_docstring)
290
+ )
291
+ for each_offset, each_prose_line in enumerate(prose.split("\n")):
292
+ all_scan_lines.append((each_start_line + each_offset, each_prose_line))
293
+ return all_scan_lines
235
294
 
236
295
 
237
- def find_violations(text: str, file_path: str) -> list[str]:
238
- """Return all violated patterns found in text for the given file.
296
+ def _markdown_scan_lines(text: str) -> list[tuple[int, str]]:
297
+ """Return each Markdown source line as ``(line_number, scannable_text)``.
239
298
 
240
- For .md files, scans the entire text. For code files, scans comment lines,
241
- and for Python files also scans module/class/function docstrings.
242
- Returns a list of matched pattern source strings.
299
+ A fenced code block's content is data rather than prose, so a line
300
+ between a pair of ``` fence markers is skipped. An inline code span is a
301
+ mention rather than a use, so it is blanked out of the line it appears
302
+ on, the same way it is blanked from Python docstring prose.
303
+
304
+ Args:
305
+ text: The Markdown source to scan.
306
+
307
+ Returns:
308
+ One entry per source line outside a fenced code block, with its
309
+ inline code spans removed.
310
+ """
311
+ all_scan_lines: list[tuple[int, str]] = []
312
+ is_inside_fence = False
313
+ for each_line_number, each_line in enumerate(text.splitlines(), 1):
314
+ if each_line.strip().startswith("```"):
315
+ is_inside_fence = not is_inside_fence
316
+ continue
317
+ if is_inside_fence:
318
+ continue
319
+ all_scan_lines.append(
320
+ (each_line_number, INLINE_CODE_PATTERN.sub("", each_line))
321
+ )
322
+ return all_scan_lines
323
+
324
+
325
+ def _first_pattern_match(
326
+ pattern: Pattern[str], all_scan_lines: list[tuple[int, str]]
327
+ ) -> tuple[str, int] | None:
328
+ """Return the first line a pattern matches, or None when it matches none."""
329
+ for each_line_number, each_line_text in all_scan_lines:
330
+ each_match = pattern.search(each_line_text)
331
+ if each_match is not None:
332
+ return (each_match.group(0).strip().lower(), each_line_number)
333
+ return None
334
+
335
+
336
+ def _scan_lines_for_file(
337
+ text: str, extension: str, file_path: str
338
+ ) -> list[tuple[int, str]] | None:
339
+ """Return the file's scannable lines, or ``None`` when its kind carries no prose.
340
+
341
+ A ``.md`` file scans every source line outside a fenced code block. A
342
+ code file scans its comment lines, and a Python file also scans its
343
+ module, class, and function docstrings.
243
344
  """
244
- extension = _get_file_extension(file_path)
245
345
  if is_markdown_file(file_path):
246
- scan_text = text
247
- elif is_comment_bearing_file(file_path):
248
- all_comment_lines = _extract_comment_lines(text, extension)
249
- scan_text = "\n".join(all_comment_lines)
250
- if extension == PYTHON_EXTENSION:
251
- scan_text = "\n".join([scan_text, _extract_python_docstring_text(text)])
252
- else:
253
- return []
346
+ return _markdown_scan_lines(text)
347
+ if not is_comment_bearing_file(file_path):
348
+ return None
349
+ all_scan_lines = _extract_comment_lines(text, extension)
350
+ if extension == PYTHON_EXTENSION:
351
+ return all_scan_lines + _docstring_scan_lines(text)
352
+ return all_scan_lines
353
+
354
+
355
+ def _matched_transition_patterns(
356
+ all_scan_lines: list[tuple[int, str]],
357
+ ) -> list[tuple[str, int]]:
358
+ """Return each transition pattern the scan lines match, with its line."""
359
+ all_detected: list[tuple[str, int]] = []
360
+ for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS:
361
+ matched_entry = _first_pattern_match(each_pattern, all_scan_lines)
362
+ if matched_entry is not None:
363
+ all_detected.append(matched_entry)
364
+ return all_detected
254
365
 
255
- if is_markdown_file(file_path):
256
- scan_text = CODE_FENCE_PATTERN.sub("", scan_text)
257
- scan_text = INLINE_CODE_PATTERN.sub("", scan_text)
258
366
 
259
- if not scan_text.strip():
367
+ def find_violations_with_lines(text: str, file_path: str) -> list[tuple[str, int]]:
368
+ """Return each violated pattern with the source line the rule matched.
369
+
370
+ ::
371
+
372
+ # config.py
373
+ 1 fixture_text = "the field was `previously` required"
374
+ 4 def read_field():
375
+ 5 \"\"\"Read the field.
376
+ 7 `The field was previously required.`
377
+ 8 \"\"\"
378
+ flag: ("previously", 7) and ("was previously", 7) -- one per pattern
379
+ ok: line 1 never reported -- a plain string literal is fixture data, not prose
380
+
381
+ Each match is paired with the line it matched on, not an earlier mention
382
+ of the same words.
383
+
384
+ Args:
385
+ text: The file content to scan.
386
+ file_path: The path that selects the scan strategy for the file.
387
+
388
+ Returns:
389
+ One ``(matched_phrase, source_line_number)`` pair per matched
390
+ pattern, in pattern-declaration order, at most one match each.
391
+ """
392
+ extension = _get_file_extension(file_path)
393
+ all_scan_lines = _scan_lines_for_file(text, extension, file_path)
394
+ if all_scan_lines is None:
260
395
  return []
396
+ return _matched_transition_patterns(all_scan_lines)
261
397
 
262
- all_detected: list[str] = []
263
- all_transition_patterns = ALL_COMMENT_TRANSITION_PATTERNS
264
- for each_pattern in all_transition_patterns:
265
- all_matches = each_pattern.findall(scan_text)
266
- if all_matches:
267
- all_detected.append(all_matches[0].strip().lower())
268
398
 
269
- return all_detected
399
+ def find_violations(text: str, file_path: str) -> list[str]:
400
+ """Return all violated patterns found in text for the given file.
401
+
402
+ For .md files, scans the entire text. For code files, scans comment lines,
403
+ and for Python files also scans module/class/function docstrings.
404
+ Returns a list of matched pattern source strings.
405
+ """
406
+ return [
407
+ each_phrase
408
+ for each_phrase, _each_line_number in find_violations_with_lines(text, file_path)
409
+ ]
270
410
 
271
411
 
272
412
  def _build_deny_reason(file_path: str, all_detected_patterns: list[str]) -> str:
@@ -1,20 +1,21 @@
1
- """Entry-point tests proving the duplicate-body check guards hook-infrastructure files.
1
+ """Entry-point tests proving the duplicate-body check's lane assignment for hook files.
2
2
 
3
3
  The cross-file duplicate-body check exists to catch a helper copied across sibling
4
4
  modules in the ``blocking/`` hook directory itself — the exact directory the rest of
5
- the code-rules suite exempts. These tests drive the real entry points (the ``main()``
6
- stdin path and the pre-check CLI) with a hook-infrastructure target so the deny fires
7
- on the same path a live Write would take, rather than calling the check function
8
- directly.
5
+ the code-rules suite exempts. CODE_RULES.md §11.6 assigns it by scope: it reads a
6
+ sibling file, so it runs on the full gate only, and a hook-infrastructure target's
7
+ live PreToolUse Write stays on the edit lane. These tests drive the shipped entry
8
+ points (the ``main()`` stdin path and the pre-check CLI) with a hook-infrastructure
9
+ target, so each lane's behavior is proven on the same path a live Write or a
10
+ pre-check run would take, rather than by calling the check function directly.
9
11
 
10
12
  Each test builds a temporary tree whose tail mirrors a production hook directory
11
13
  (``packages/claude-dev-env/hooks/blocking``) so ``is_hook_infrastructure`` matches the
12
- target path the same way it would for the real directory.
14
+ target path the same way it would for the production directory.
13
15
  """
14
16
 
15
17
  from __future__ import annotations
16
18
 
17
- import json
18
19
  import pathlib
19
20
  import shutil
20
21
  import sys
@@ -75,27 +76,22 @@ def _run_main_with_write_payload(
75
76
  return captured_stdout
76
77
 
77
78
 
78
- def test_write_of_copied_helper_into_hook_directory_denies(
79
+ def test_write_of_copied_helper_into_hook_directory_does_not_deny(
79
80
  hook_blocking_dir: pathlib.Path,
80
81
  ) -> None:
81
- """A Write that copies a sibling helper into a second hook file is denied.
82
+ """A live Write that copies a sibling helper into a second hook file is not denied.
82
83
 
83
- The target lives under a hook-infrastructure path the rest of the code-rules
84
- suite exempts, so this proves the duplicate-body check still guards the exact
85
- directory its module docstring names as the primary target."""
84
+ Enforces CODE_RULES.md §11.6: "A check that reads a file other than the
85
+ target runs on the full gate." The cross-file duplicate-body check reads a
86
+ sibling module, so it is full-gate only; the live PreToolUse Write that
87
+ ``main()`` handles runs the edit lane, so it must not deny here even though
88
+ the copied helper is present."""
86
89
  (hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
87
90
  new_file = hook_blocking_dir / "new_blocker.py"
88
91
  stdout = _run_main_with_write_payload(str(new_file), SHARED_HELPER_SOURCE)
89
- assert stdout != "", (
90
- "A copied helper written into a second hook-infrastructure file must "
91
- "produce a deny payload, got empty stdout"
92
- )
93
- deny_payload = json.loads(stdout)
94
- decision = deny_payload["hookSpecificOutput"]["permissionDecision"]
95
- reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
96
- assert decision == "deny", f"expected deny, got: {decision!r}"
97
- assert "strip_code_and_quotes" in reason, (
98
- f"the deny reason must name the duplicated helper, got: {reason!r}"
92
+ assert stdout == "", (
93
+ "the edit lane must not run the cross-file duplicate-body check, "
94
+ f"got stdout: {stdout!r}"
99
95
  )
100
96
 
101
97
 
@@ -124,9 +120,11 @@ def test_precheck_of_copied_helper_at_hook_target_exits_nonzero(
124
120
  ) -> None:
125
121
  """The pre-check CLI flags a copied helper judged at a hook-infrastructure target.
126
122
 
127
- Driving the real ``--check`` argv path proves the gate's pre-check mode also
128
- routes a hook ``.py`` target through the duplicate-body check rather than
129
- exiting clean on the blanket hook-infrastructure exemption."""
123
+ Enforces CODE_RULES.md §11.6: hook-infrastructure targets "run ... the
124
+ whole roster on the full gate." The pre-check CLI runs the full gate, so
125
+ driving the shipped ``--check`` argv path proves it still routes a hook
126
+ ``.py`` target through the duplicate-body check rather than exiting clean
127
+ on the blanket hook-infrastructure exemption."""
130
128
  (hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
131
129
  staging_directory = tmp_path
132
130
  candidate_file = staging_directory / "candidate.py"
@@ -26,6 +26,7 @@ if _HOOKS_ROOT not in sys.path:
26
26
  from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
27
27
  from state_description_blocker import ( # noqa: E402
28
28
  build_deny_payload,
29
+ find_violations_with_lines,
29
30
  )
30
31
  from state_description_blocker import evaluate as _evaluate_without_opt_in # noqa: E402
31
32
 
@@ -952,3 +953,45 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
952
953
  logged_record = json.loads(all_records[0])
953
954
  assert logged_record["hook"] == "state_description_blocker.py"
954
955
  assert logged_record["event"] == "PreToolUse"
956
+
957
+
958
+ def test_find_violations_with_lines_names_the_docstring_line_not_the_fixture_line() -> None:
959
+ module_source = (
960
+ 'fixture_text = "the field was previously required"\n'
961
+ "\n"
962
+ "\n"
963
+ "def read_field():\n"
964
+ ' """Read the field.\n'
965
+ "\n"
966
+ " The field was previously required.\n"
967
+ ' """\n'
968
+ " return 1\n"
969
+ )
970
+
971
+ all_violations = find_violations_with_lines(module_source, "src/module.py")
972
+
973
+ assert all_violations == [("previously", 7), ("was previously", 7)]
974
+
975
+
976
+ def test_find_violations_with_lines_reports_first_matching_comment_line() -> None:
977
+ module_source = "x = 1\n# no longer needed\ny = 2\n# no longer used\n"
978
+
979
+ all_violations = find_violations_with_lines(module_source, "src/module.py")
980
+
981
+ assert all_violations == [("no longer", 2)]
982
+
983
+
984
+ def test_find_violations_with_lines_reports_markdown_line_past_a_code_fence() -> None:
985
+ markdown_source = (
986
+ "# Title\n"
987
+ "\n"
988
+ "```\n"
989
+ "used to fail\n"
990
+ "```\n"
991
+ "\n"
992
+ "The API was previously public.\n"
993
+ )
994
+
995
+ all_violations = find_violations_with_lines(markdown_source, "docs/api.md")
996
+
997
+ assert all_violations == [("previously", 7), ("was previously", 7)]
@@ -40,12 +40,16 @@ ALL_COMMENT_TRANSITION_PATTERNS: list[Pattern[str]] = [
40
40
  compile(r"\bas of\b", IGNORECASE),
41
41
  ]
42
42
 
43
- CODE_FENCE_PATTERN: Pattern[str] = compile(r"```[\s\S]*?```")
43
+ BLOCK_COMMENT_OPEN_MARKER: str = "/*"
44
+ BLOCK_COMMENT_CLOSE_MARKER: str = "*/"
45
+
44
46
  INLINE_CODE_PATTERN: Pattern[str] = compile(r"``[^`]+``|`[^`]+`")
45
47
  DOUBLE_QUOTED_SPAN_PATTERN: Pattern[str] = compile(r'"[^"\n]*"')
46
48
  TRIPLE_QUOTED_BLOCK_PATTERN: Pattern[str] = compile(
47
49
  r'"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\''
48
50
  )
51
+ DOUBLE_QUOTE_BODY_GROUP: int = 1
52
+ SINGLE_QUOTE_BODY_GROUP: int = 2
49
53
 
50
54
  PYTHON_EXTENSION: str = ".py"
51
55
 
@@ -0,0 +1,85 @@
1
+ """Behavior tests for state_description_blocker configuration constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
9
+ if str(_HOOKS_ROOT) not in sys.path:
10
+ sys.path.insert(0, str(_HOOKS_ROOT))
11
+
12
+ from blocking.state_description_blocker import _extract_comment_lines
13
+ from hooks_constants.state_description_blocker_constants import (
14
+ ALL_BLOCK_COMMENT_EXTENSIONS,
15
+ ALL_COMMENT_TRANSITION_PATTERNS,
16
+ ALL_HASH_ONLY_EXTENSIONS,
17
+ ALL_MARKDOWN_EXTENSIONS,
18
+ BLOCK_COMMENT_CLOSE_MARKER,
19
+ BLOCK_COMMENT_OPEN_MARKER,
20
+ DOUBLE_QUOTE_BODY_GROUP,
21
+ SINGLE_QUOTE_BODY_GROUP,
22
+ TRIPLE_QUOTED_BLOCK_PATTERN,
23
+ )
24
+
25
+
26
+ def test_transition_patterns_match_their_own_phrase() -> None:
27
+ all_phrases = [
28
+ "instead of",
29
+ "previously",
30
+ "was previously",
31
+ "used to",
32
+ "no longer",
33
+ "switched to",
34
+ ]
35
+ for each_phrase in all_phrases:
36
+ sentence = f"The field is {each_phrase} required."
37
+ assert any(
38
+ each_pattern.search(sentence) for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS
39
+ ), each_phrase
40
+
41
+
42
+ def test_transition_patterns_do_not_match_unrelated_prose() -> None:
43
+ sentence = "The path selects the scan strategy for the file."
44
+ assert not any(
45
+ each_pattern.search(sentence) for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS
46
+ )
47
+
48
+
49
+ def test_block_comment_markers_close_a_single_line_block_comment() -> None:
50
+ text = f"{BLOCK_COMMENT_OPEN_MARKER} note {BLOCK_COMMENT_CLOSE_MARKER}\ncode();"
51
+ all_comment_lines = _extract_comment_lines(text, ".js")
52
+ assert all_comment_lines == [
53
+ (1, f"{BLOCK_COMMENT_OPEN_MARKER} note {BLOCK_COMMENT_CLOSE_MARKER}")
54
+ ]
55
+
56
+
57
+ def test_block_comment_markers_carry_an_unclosed_comment_to_the_next_line() -> None:
58
+ text = f"{BLOCK_COMMENT_OPEN_MARKER} still open\nclosed here {BLOCK_COMMENT_CLOSE_MARKER}"
59
+ all_comment_lines = _extract_comment_lines(text, ".js")
60
+ assert all_comment_lines == [
61
+ (1, f"{BLOCK_COMMENT_OPEN_MARKER} still open"),
62
+ (2, f"closed here {BLOCK_COMMENT_CLOSE_MARKER}"),
63
+ ]
64
+
65
+
66
+ def test_double_and_single_quote_body_groups_index_the_matching_quote_style() -> None:
67
+ double_quoted_match = TRIPLE_QUOTED_BLOCK_PATTERN.search('"""double body"""')
68
+ assert double_quoted_match is not None
69
+ assert double_quoted_match.group(DOUBLE_QUOTE_BODY_GROUP) == "double body"
70
+ assert double_quoted_match.group(SINGLE_QUOTE_BODY_GROUP) is None
71
+
72
+ single_quoted_match = TRIPLE_QUOTED_BLOCK_PATTERN.search("'''single body'''")
73
+ assert single_quoted_match is not None
74
+ assert single_quoted_match.group(SINGLE_QUOTE_BODY_GROUP) == "single body"
75
+ assert single_quoted_match.group(DOUBLE_QUOTE_BODY_GROUP) is None
76
+
77
+
78
+ def test_extension_sets_are_disjoint_from_markdown_extensions() -> None:
79
+ assert not (ALL_HASH_ONLY_EXTENSIONS & ALL_MARKDOWN_EXTENSIONS)
80
+ assert not (ALL_BLOCK_COMMENT_EXTENSIONS & ALL_MARKDOWN_EXTENSIONS)
81
+
82
+
83
+ def test_python_is_hash_only_and_never_block_comment() -> None:
84
+ assert ".py" in ALL_HASH_ONLY_EXTENSIONS
85
+ assert ".py" not in ALL_BLOCK_COMMENT_EXTENSIONS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.6.0",
3
+ "version": "8.6.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -318,7 +318,7 @@ def state_description_diagnostics(
318
318
  """
319
319
  absolute_path = adapter_support._document_path(repository_root, document)
320
320
  detector_module = load_module("blocking.state_description_blocker")
321
- all_messages = detector_module.find_violations(
321
+ all_messages = detector_module.find_violations_with_lines(
322
322
  document.text, absolute_path.as_posix()
323
323
  )
324
324
  return adapter_support._diagnostics_for_state_messages(document, all_messages)
@@ -81,23 +81,12 @@ def _diagnostics_for_messages(
81
81
  return tuple(all_diagnostics)
82
82
 
83
83
 
84
- def _source_line_for_phrase(source_text: str, phrase: str) -> int | None:
85
- normalized_phrase = phrase.casefold()
86
- for each_line_number, each_line in enumerate(source_text.splitlines(), 1):
87
- if normalized_phrase in each_line.casefold():
88
- return each_line_number
89
- return None
90
-
91
-
92
84
  def _diagnostics_for_state_messages(
93
- document: Document, all_messages: Iterable[str]
85
+ document: Document, all_messages: Iterable[tuple[str, int]]
94
86
  ) -> tuple[Diagnostic, ...]:
95
87
  all_diagnostics: list[Diagnostic] = []
96
- for each_message in all_messages:
97
- maybe_line = _source_line_for_phrase(document.text, each_message)
98
- location = (
99
- None if maybe_line is None else Location(document.path, maybe_line, 1)
100
- )
88
+ for each_message, each_line_number in all_messages:
89
+ location = Location(document.path, each_line_number, 1)
101
90
  all_diagnostics.append(
102
91
  Diagnostic("state-description", Severity.ERROR, each_message, location)
103
92
  )
@@ -21,7 +21,6 @@ from .model import Diagnostic, Document, DocumentSet
21
21
  _document_path = adapter_support._document_path
22
22
  _line_number = adapter_support._line_number
23
23
  _diagnostics_for_messages = adapter_support._diagnostics_for_messages
24
- _source_line_for_phrase = adapter_support._source_line_for_phrase
25
24
  _diagnostics_for_state_messages = adapter_support._diagnostics_for_state_messages
26
25
 
27
26
 
@@ -296,7 +296,10 @@ def test_state_description_diagnostics_maps_and_scopes_phrases(
296
296
  adapters,
297
297
  "_hooks_module",
298
298
  lambda _module_name: types.SimpleNamespace(
299
- find_violations=lambda _text, _path: ["used to", "replaced by"]
299
+ find_violations_with_lines=lambda _text, _path: [
300
+ ("used to", 2),
301
+ ("replaced by", 1),
302
+ ]
300
303
  ),
301
304
  )
302
305
  source_document = Document(