claude-dev-env 8.5.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/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +15 -8
- 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/followup_ledger.py +109 -4
- package/hooks/hooks_constants/followup_ledger_constants.py +15 -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/hooks/test_followup_ledger.py +92 -4
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +35 -2
- package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
- package/scripts/followup_cli.py +78 -4
- package/scripts/policy_lint/adapter_detectors.py +1 -1
- package/scripts/policy_lint/adapter_support.py +11 -15
- package/scripts/policy_lint/adapters.py +0 -1
- package/scripts/policy_lint/check_catalog.py +44 -0
- package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
- package/scripts/policy_lint/model.py +4 -1
- package/scripts/test_followup_cli.py +116 -2
- package/scripts/test_validate_instruction_pairs.py +23 -1
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
- package/scripts/validate_instruction_pairs.py +9 -2
|
@@ -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"
|
package/bin/cde.mjs
CHANGED
|
@@ -7,16 +7,18 @@ import path from "node:path";
|
|
|
7
7
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
8
|
const lintScriptPath = path.join(packageRoot, "scripts", "cde_lint.py");
|
|
9
9
|
const verificationScriptPath = path.join(packageRoot, "scripts", "local_verification", "cli.py");
|
|
10
|
+
const followupScriptPath = path.join(packageRoot, "scripts", "followup_cli.py");
|
|
10
11
|
const posixSignalStatusOffset = 128;
|
|
11
12
|
const lintCommandName = "lint";
|
|
12
13
|
const verifyCommandName = "verify";
|
|
14
|
+
const followupCommandName = "followup";
|
|
13
15
|
const pythonOptionName = "--python";
|
|
14
16
|
const pythonEnvironmentName = "CDE_PYTHON";
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
export function createHelpText() {
|
|
18
20
|
return [
|
|
19
|
-
"Usage: cde <lint|verify> [options]",
|
|
21
|
+
"Usage: cde <lint|verify|followup> [options]",
|
|
20
22
|
"",
|
|
21
23
|
"Run the policy linter with cde lint. Source modes are mutually exclusive:",
|
|
22
24
|
" --files PATH [PATH ...] Current worktree files",
|
|
@@ -37,6 +39,13 @@ export function createHelpText() {
|
|
|
37
39
|
" --base SHA Trusted base revision",
|
|
38
40
|
" --output PATH JSON verification report",
|
|
39
41
|
"",
|
|
42
|
+
"Read the follow-up ledger of non-breaking findings with cde followup:",
|
|
43
|
+
" cde followup list Name every recorded follow-up",
|
|
44
|
+
" cde followup ingest PATH Record a policy-lint JSON report",
|
|
45
|
+
" cde followup brief Write the task an agent fixes them from",
|
|
46
|
+
" cde followup count Report the backlog against the threshold",
|
|
47
|
+
" cde followup clear Empty the ledger",
|
|
48
|
+
"",
|
|
40
49
|
"Python may also be selected with CDE_PYTHON.",
|
|
41
50
|
].join("\n");
|
|
42
51
|
}
|
|
@@ -75,6 +84,14 @@ export function buildVerifyCommand(interpreter, forwardedArguments) {
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
|
|
87
|
+
export function buildFollowupCommand(interpreter, forwardedArguments) {
|
|
88
|
+
return {
|
|
89
|
+
executable: interpreter,
|
|
90
|
+
arguments: [followupScriptPath, ...forwardedArguments],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
78
95
|
function takePythonOverride(allArguments) {
|
|
79
96
|
const remainingArguments = [];
|
|
80
97
|
let interpreterOverride;
|
|
@@ -170,12 +187,31 @@ export function runVerifyCommand(command, dependencies = {}) {
|
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
|
|
190
|
+
export function runFollowupCommand(command, dependencies = {}) {
|
|
191
|
+
return runChildCommand(command, "Unable to start the follow-up command.", dependencies);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
const buildCommandByName = {
|
|
196
|
+
[lintCommandName]: buildLintCommand,
|
|
197
|
+
[verifyCommandName]: buildVerifyCommand,
|
|
198
|
+
[followupCommandName]: buildFollowupCommand,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
const runCommandByName = {
|
|
203
|
+
[lintCommandName]: runLintCommand,
|
|
204
|
+
[verifyCommandName]: runVerifyCommand,
|
|
205
|
+
[followupCommandName]: runFollowupCommand,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
|
|
173
209
|
export async function main(argumentsList = process.argv.slice(2), dependencies = {}) {
|
|
174
210
|
if (argumentsList.length === 0 || argumentsList.includes("--help") || argumentsList.includes("-h")) {
|
|
175
211
|
process.stdout.write(`${createHelpText()}\n`);
|
|
176
212
|
return 0;
|
|
177
213
|
}
|
|
178
|
-
if (!
|
|
214
|
+
if (!Object.hasOwn(buildCommandByName, argumentsList[0])) {
|
|
179
215
|
process.stderr.write(`${createHelpText()}\n`);
|
|
180
216
|
return 2;
|
|
181
217
|
}
|
|
@@ -190,10 +226,8 @@ export async function main(argumentsList = process.argv.slice(2), dependencies =
|
|
|
190
226
|
process.stderr.write("No usable Python interpreter found. Use --python or CDE_PYTHON.\n");
|
|
191
227
|
return 2;
|
|
192
228
|
}
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const runCommand = dependencies.runCommand
|
|
196
|
-
?? (argumentsList[0] === lintCommandName ? runLintCommand : runVerifyCommand);
|
|
229
|
+
const command = buildCommandByName[argumentsList[0]](interpreter, remainingArguments);
|
|
230
|
+
const runCommand = dependencies.runCommand ?? runCommandByName[argumentsList[0]];
|
|
197
231
|
return await runCommand(command, dependencies);
|
|
198
232
|
}
|
|
199
233
|
|
package/bin/cde.test.mjs
CHANGED
|
@@ -268,3 +268,28 @@ test("reports a synchronous child start failure as invalid input", async () => {
|
|
|
268
268
|
assert.equal(exitCode, 2);
|
|
269
269
|
assert.deepEqual(messages, ["Unable to start the policy linter.\n"]);
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
test("dispatches followup to the follow-up ledger command", async () => {
|
|
274
|
+
let receivedCommand;
|
|
275
|
+
const exitCode = await main(
|
|
276
|
+
["followup", "--python", "python-test", "list"],
|
|
277
|
+
{
|
|
278
|
+
findPython: async () => "unused-python",
|
|
279
|
+
runCommand: async (command) => {
|
|
280
|
+
receivedCommand = command;
|
|
281
|
+
return 0;
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
assert.equal(exitCode, 0);
|
|
286
|
+
assert.equal(receivedCommand.executable, "python-test");
|
|
287
|
+
assert.match(receivedCommand.arguments[0], /followup_cli\.py$/);
|
|
288
|
+
assert.deepEqual(receivedCommand.arguments.slice(1), ["list"]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
test("help names the followup command", () => {
|
|
293
|
+
assert.match(createHelpText(), /cde <lint\|verify\|followup>/);
|
|
294
|
+
assert.match(createHelpText(), /cde followup count/);
|
|
295
|
+
});
|
|
@@ -18,14 +18,20 @@ Run `cde followup brief --repository-root <root>`.
|
|
|
18
18
|
When it prints `no follow-ups recorded`, report that and stop. There is no
|
|
19
19
|
work.
|
|
20
20
|
|
|
21
|
-
Otherwise the brief lists one line per finding, each carrying the
|
|
22
|
-
identifier, the file, and the
|
|
21
|
+
Otherwise the brief lists one line per finding, each carrying the check
|
|
22
|
+
identifier, the file, the message, and the revision the finding was recorded
|
|
23
|
+
against.
|
|
23
24
|
|
|
24
25
|
## 2. Group the findings
|
|
25
26
|
|
|
26
|
-
Group the lines by
|
|
27
|
+
Group the lines by check identifier. A check with several findings takes one
|
|
27
28
|
pass over every file it names, so the same fix shape lands once.
|
|
28
29
|
|
|
30
|
+
A finding whose check identifier ends in `unclassified` comes from a check the
|
|
31
|
+
catalog does not name yet. Fix it like any other, and add its entry to
|
|
32
|
+
`scripts/policy_lint/config/check_catalog_constants.py` with a sample in the
|
|
33
|
+
catalog's synchronization test.
|
|
34
|
+
|
|
29
35
|
Read each named file before editing it. A recorded finding names the state of
|
|
30
36
|
the file at the moment the gate ran, so confirm the finding still holds.
|
|
31
37
|
Keep a finding the current file still carries. Drop the rest.
|
|
@@ -41,14 +47,15 @@ finding names a generated file, regenerate it with the repository's tooling.
|
|
|
41
47
|
|
|
42
48
|
## 4. Open the pull request
|
|
43
49
|
|
|
44
|
-
Branch from the repository's default branch. Commit each
|
|
45
|
-
|
|
50
|
+
Branch from the repository's default branch. Commit each group on its own,
|
|
51
|
+
with a message naming the check identifier and the files.
|
|
46
52
|
|
|
47
53
|
Open the pull request as a draft. Write the body as:
|
|
48
54
|
|
|
49
55
|
- One `Before:` paragraph saying which findings stood open.
|
|
50
56
|
- One `After:` paragraph saying what the tree now holds.
|
|
51
|
-
- A short `How` paragraph naming the
|
|
57
|
+
- A short `How` paragraph naming the groups, the files each touched, and the
|
|
58
|
+
revisions the findings were recorded against.
|
|
52
59
|
|
|
53
60
|
Push, then confirm the required checks report on the branch head.
|
|
54
61
|
|
|
@@ -62,5 +69,5 @@ A finding the pull request left alone goes back in the ledger with
|
|
|
62
69
|
|
|
63
70
|
## Report
|
|
64
71
|
|
|
65
|
-
Name the pull request link, the
|
|
66
|
-
|
|
72
|
+
Name the pull request link, the groups fixed, the findings dropped because
|
|
73
|
+
the current file reads clean, and anything left in the ledger.
|