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.
Files changed (29) hide show
  1. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
  2. package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
  3. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
  4. package/bin/cde.mjs +40 -6
  5. package/bin/cde.test.mjs +25 -0
  6. package/commands/fix-followups.md +15 -8
  7. package/hooks/blocking/state_description_blocker.py +230 -90
  8. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
  9. package/hooks/blocking/test_state_description_blocker.py +43 -0
  10. package/hooks/followup_ledger.py +109 -4
  11. package/hooks/hooks_constants/followup_ledger_constants.py +15 -0
  12. package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
  13. package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
  14. package/hooks/test_followup_ledger.py +92 -4
  15. package/package.json +1 -1
  16. package/rules/flag-non-breaking-findings.md +35 -2
  17. package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
  18. package/scripts/followup_cli.py +78 -4
  19. package/scripts/policy_lint/adapter_detectors.py +1 -1
  20. package/scripts/policy_lint/adapter_support.py +11 -15
  21. package/scripts/policy_lint/adapters.py +0 -1
  22. package/scripts/policy_lint/check_catalog.py +44 -0
  23. package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
  24. package/scripts/policy_lint/model.py +4 -1
  25. package/scripts/test_followup_cli.py +116 -2
  26. package/scripts/test_validate_instruction_pairs.py +23 -1
  27. package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
  28. package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
  29. 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 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"
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 (![lintCommandName, verifyCommandName].includes(argumentsList[0])) {
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 buildCommand = argumentsList[0] === lintCommandName ? buildLintCommand : buildVerifyCommand;
194
- const command = buildCommand(interpreter, remainingArguments);
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 rule
22
- identifier, the file, and the message.
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 rule identifier. A rule with several findings takes one
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 rule group on its
45
- own, with a message naming the rule identifier and the files.
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 rule groups and the files each touched.
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 rule groups fixed, the findings dropped as no
66
- longer present, and anything left in the ledger.
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.