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.
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
- package/hooks/blocking/state_description_blocker.py +230 -90
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
- package/hooks/blocking/test_state_description_blocker.py +43 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_detectors.py +1 -1
- package/scripts/policy_lint/adapter_support.py +3 -14
- package/scripts/policy_lint/adapters.py +0 -1
- package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
|
@@ -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
|
|
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(
|
|
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
|
|
181
|
-
backticked span names code verbatim
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
247
|
+
return _split_into_prose_fragments(line_text)
|
|
196
248
|
if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
197
249
|
return []
|
|
198
|
-
|
|
250
|
+
all_raw_fragments: list[str] = []
|
|
199
251
|
stripped_line = line_text.strip()
|
|
200
252
|
if stripped_line.startswith(JSDOC_CONTINUATION_MARKER):
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
return
|
|
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``);
|
|
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(
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
|
99
|
-
is_in_block_comment =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
"""
|
|
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
|
|
183
|
-
a def/class header ending in a colon. A block assigned to a name or
|
|
184
|
-
elsewhere in the fragment is data
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
213
|
-
"""Return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
238
|
-
"""Return
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
"
|
|
91
|
-
"
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|