claude-dev-env 1.57.2 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +317 -54
  10. package/bin/install.test.mjs +478 -3
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. package/skills/update/SKILL.md +37 -5
package/CLAUDE.md CHANGED
@@ -45,8 +45,8 @@ Reserve `Read`/`Grep`/`Glob` for files you will actually touch this turn. Compos
45
45
 
46
46
  Run every multi-step code task in two phases:
47
47
 
48
- 1. **Coders** — one Sonnet agent per scoped assignment writes the code. A coder that hits a decision it can't reasonably solve consults the tool-less `fable-advisor` agent — which returns a plan, a correction, or a stop signal — and resumes. Source: Anthropic's advisor strategy (https://claude.com/blog/the-advisor-strategy).
49
- 2. **Verification** — when the coders finish, the main session spawns the `fable-verifier` agent in a fresh context. It derives and runs the checks itself rather than trusting coder reports: the task's named gates, tests against baselines recorded before the coders ran, and a two-way diff-vs-assignment reading (every task item maps to a hunk, every hunk maps to a task item, nothing missing). A finding must cite a failing command or a named task item. Source: the fresh-context review step in Claude Code best practices (https://code.claude.com/docs/en/best-practices) — the agent doing the work isn't the one grading it.
48
+ 1. **Coders** — one coder agent per scoped assignment writes the code. A coder that hits a decision it can't reasonably solve consults the tool-less `code-advisor` agent — which returns a plan, a correction, or a stop signal — and resumes. Source: Anthropic's advisor strategy (https://claude.com/blog/the-advisor-strategy).
49
+ 2. **Verification** — when the coders finish, the main session spawns the `code-verifier` agent in a fresh context. It derives and runs the checks itself rather than trusting coder reports: the task's named gates, tests against baselines recorded before the coders ran, and a two-way diff-vs-assignment reading (every task item maps to a hunk, every hunk maps to a task item, nothing missing). A finding must cite a failing command or a named task item. Source: the fresh-context review step in Claude Code best practices (https://code.claude.com/docs/en/best-practices) — the agent doing the work isn't the one grading it.
50
50
 
51
51
  Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. The one exemption is mechanical, not discretionary: a diff whose every changed file is non-code or has an unchanged Python AST once docstrings are stripped (docs, docstrings, comments).
52
52
 
@@ -22,6 +22,9 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
22
22
  BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
23
23
  BANNED_NOUN_SPAN_GROUP_INDEX,
24
24
  BANNED_NOUN_VIOLATION_PATTERN,
25
+ DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
26
+ DUPLICATE_BODY_SPAN_GROUP_INDEX,
27
+ DUPLICATE_BODY_VIOLATION_PATTERN,
25
28
  FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
26
29
  FUNCTION_LENGTH_SPAN_GROUP_INDEX,
27
30
  FUNCTION_LENGTH_VIOLATION_PATTERN,
@@ -1006,11 +1009,39 @@ def banned_noun_span_range(violation_text: str) -> range | None:
1006
1009
  return range(definition_line, definition_line + line_span)
1007
1010
 
1008
1011
 
1012
+ def duplicate_body_span_range(violation_text: str) -> range | None:
1013
+ """Return the copied function's source line range of a duplicate-body issue.
1014
+
1015
+ The duplicate-body message carries the copied function's definition line and
1016
+ its full body span: ``Function 'NAME' duplicates location.py::name — ...
1017
+ (duplicate body span at line X, spanning Y lines)``. The function occupies
1018
+ lines ``X`` through ``X + Y - 1`` inclusive, so a duplicate of a sibling helper
1019
+ is blocking only when the diff touches the copied function and advisory when an
1020
+ unrelated edit leaves a pre-existing copy untouched — matching the span-scoped
1021
+ PreToolUse Write/Edit behavior rather than blocking every duplicate-body
1022
+ message unconditionally.
1023
+
1024
+ Args:
1025
+ violation_text: A single violation string emitted by the enforcer.
1026
+
1027
+ Returns:
1028
+ A ``range`` covering the copied function's declared line span, or None
1029
+ when the text is not a duplicate-body violation.
1030
+ """
1031
+ span_match = DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
1032
+ if span_match is None:
1033
+ return None
1034
+ definition_line = int(span_match.group(DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX))
1035
+ line_span = int(span_match.group(DUPLICATE_BODY_SPAN_GROUP_INDEX))
1036
+ return range(definition_line, definition_line + line_span)
1037
+
1038
+
1009
1039
  def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
1010
1040
  return (
1011
1041
  function_length_span_range,
1012
1042
  isolation_span_range,
1013
1043
  banned_noun_span_range,
1044
+ duplicate_body_span_range,
1014
1045
  )
1015
1046
 
1016
1047
 
@@ -1052,9 +1083,10 @@ def split_violations_by_scope(
1052
1083
  Returns:
1053
1084
  Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
1054
1085
  None, every issue is blocking. Every diff-scoped violation
1055
- (function-length, HOME/TMP isolation, banned-noun) carries an
1056
- enclosing-unit span fragment that ``enclosing_span_range`` reconstructs
1057
- through one shared extractor registry; such a violation is blocking
1086
+ (function-length, HOME/TMP isolation, banned-noun, duplicate-body)
1087
+ carries an enclosing-unit span fragment that ``enclosing_span_range``
1088
+ reconstructs through one shared extractor registry; such a violation is
1089
+ blocking
1058
1090
  when its declared span intersects the added lines (the unit grew or its
1059
1091
  signature changed in this diff) and advisory otherwise (a pre-existing
1060
1092
  untouched unit). Every other issue is blocking when its ``Line N:``
@@ -1256,6 +1288,7 @@ def _scoped_violations_for_file(
1256
1288
  relative_posix,
1257
1289
  prior_content,
1258
1290
  defer_scope_to_caller=True,
1291
+ sibling_directory=resolved_path.parent,
1259
1292
  )
1260
1293
  issues.extend(check_wrapper_plumb_through(content, relative_posix))
1261
1294
  if not issues:
@@ -23,6 +23,12 @@ BANNED_NOUN_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
23
23
  BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX: int = 1
24
24
  BANNED_NOUN_SPAN_GROUP_INDEX: int = 2
25
25
 
26
+ DUPLICATE_BODY_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
27
+ r"\(duplicate body span at line (\d+), spanning (\d+) lines\)"
28
+ )
29
+ DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX: int = 1
30
+ DUPLICATE_BODY_SPAN_GROUP_INDEX: int = 2
31
+
26
32
  ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
27
33
  {".py", ".js", ".ts", ".tsx", ".jsx"}
28
34
  )
@@ -6,4 +6,5 @@ CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME: str = "CLAUDE_REVIEWS_DISABLED"
6
6
  CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR: str = ","
7
7
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: str = "bugteam"
8
8
  CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: str = "bugbot"
9
+ CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN: str = "copilot"
9
10
  EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV: int = 7
@@ -14,6 +14,7 @@ import sys
14
14
  from pr_loop_shared_constants.reviews_disabled_constants import (
15
15
  CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
16
16
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
17
+ CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
17
18
  CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
18
19
  CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR,
19
20
  EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV,
@@ -23,11 +24,13 @@ from pr_loop_shared_constants.reviews_disabled_constants import (
23
24
  __all__ = [
24
25
  "CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN",
25
26
  "CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN",
27
+ "CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN",
26
28
  "CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME",
27
29
  "CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR",
28
30
  "EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV",
29
31
  "is_bugbot_disabled_via_env",
30
32
  "is_bugteam_disabled_via_env",
33
+ "is_copilot_disabled_via_env",
31
34
  "main",
32
35
  ]
33
36
 
@@ -73,6 +76,15 @@ def is_bugbot_disabled_via_env() -> bool:
73
76
  return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN)
74
77
 
75
78
 
79
+ def is_copilot_disabled_via_env() -> bool:
80
+ """Check whether CLAUDE_REVIEWS_DISABLED opts GitHub Copilot out.
81
+
82
+ Returns:
83
+ True when the env var lists the ``copilot`` token.
84
+ """
85
+ return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN)
86
+
87
+
76
88
  def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
77
89
  """Parse command-line arguments for the reviewer opt-out check.
78
90
 
@@ -12,6 +12,7 @@ import inspect
12
12
  import subprocess
13
13
  import sys
14
14
  import unittest.mock
15
+ from collections.abc import Callable
15
16
  from pathlib import Path
16
17
  from types import ModuleType
17
18
 
@@ -31,6 +32,22 @@ def _load_gate_module() -> ModuleType:
31
32
  gate_module = _load_gate_module()
32
33
 
33
34
 
35
+ def _load_duplicate_body_check() -> Callable[..., list[str]]:
36
+ package_root = gate_module.resolve_claude_dev_env_root(
37
+ Path(gate_module.__file__).resolve()
38
+ )
39
+ module_path = package_root / "hooks" / "blocking" / "code_rules_duplicate_body.py"
40
+ spec = importlib.util.spec_from_file_location("code_rules_duplicate_body", module_path)
41
+ assert spec is not None
42
+ assert spec.loader is not None
43
+ module = importlib.util.module_from_spec(spec)
44
+ spec.loader.exec_module(module)
45
+ return module.check_duplicate_function_body_across_files
46
+
47
+
48
+ check_duplicate_function_body_across_files = _load_duplicate_body_check()
49
+
50
+
34
51
  def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
35
52
  completion = subprocess.run(
36
53
  ["git", *arguments],
@@ -560,6 +577,254 @@ def test_collect_partitioned_violations_counts_unreadable_sibling_as_skip(
560
577
  assert skipped_unreadable_count == 1
561
578
 
562
579
 
580
+ _DUPLICATE_HELPER_SOURCE = (
581
+ "import re\n"
582
+ "\n"
583
+ "def strip_code_and_quotes(text: str) -> str:\n"
584
+ ' """Strip fences, inline code, and quoted lines from text.\n'
585
+ "\n"
586
+ " Args:\n"
587
+ " text: The raw text to clean.\n"
588
+ "\n"
589
+ " Returns:\n"
590
+ " The cleaned text.\n"
591
+ ' """\n'
592
+ " without_fences = re.sub(r'```.*?```', '', text, flags=re.DOTALL)\n"
593
+ " without_inline = re.sub(r'`[^`]*`', '', without_fences)\n"
594
+ " without_quotes = re.sub(r'(?m)^>.*$', '', without_inline)\n"
595
+ " return without_quotes.strip()\n"
596
+ )
597
+
598
+
599
+ def test_run_gate_flags_copied_sibling_when_cwd_is_outside_repo_root(
600
+ temporary_git_repository: Path,
601
+ monkeypatch: pytest.MonkeyPatch,
602
+ ) -> None:
603
+ """The duplicate-body sibling scan must anchor to the repo, not process CWD.
604
+
605
+ The duplicate-body check reads sibling modules from disk to flag a copied
606
+ helper. When the gate runs with a working directory above the repository
607
+ root, resolving the sibling directory against the process CWD points at the
608
+ wrong place and the copied helper slips through. Driving the gate's per-file
609
+ validation from a parent directory with a nested byte-identical sibling
610
+ proves sibling resolution is anchored to the absolute file location rather
611
+ than the inherited CWD — the duplicate message must appear in the blocking
612
+ set.
613
+ """
614
+ package_directory = temporary_git_repository / "package"
615
+ package_directory.mkdir()
616
+ existing_file = package_directory / "existing_helper.py"
617
+ copied_file = package_directory / "copied_helper.py"
618
+ existing_file.write_text(_DUPLICATE_HELPER_SOURCE, encoding="utf-8")
619
+ copied_file.write_text(_DUPLICATE_HELPER_SOURCE, encoding="utf-8")
620
+ validate_content = gate_module.load_validate_content()
621
+
622
+ monkeypatch.chdir(temporary_git_repository.parent)
623
+ blocking_by_file, _advisory_by_file, _skipped = (
624
+ gate_module._collect_partitioned_violations(
625
+ validate_content,
626
+ [copied_file],
627
+ temporary_git_repository,
628
+ None,
629
+ )
630
+ )
631
+
632
+ all_blocking_messages = [
633
+ each_message
634
+ for each_file_messages in blocking_by_file.values()
635
+ for each_message in each_file_messages
636
+ ]
637
+ assert any(
638
+ "duplicates existing_helper.py" in each_message
639
+ for each_message in all_blocking_messages
640
+ ), (
641
+ "A copied sibling helper must be flagged even when the gate runs from a "
642
+ f"CWD above the repository root, got: {all_blocking_messages}"
643
+ )
644
+
645
+
646
+ def _duplicate_body_issue_for_copied_sibling(base_directory: Path) -> str:
647
+ """Return the enforcer's duplicate-body message for a copied sibling helper.
648
+
649
+ Writes the shared helper into a ``blocking`` subdirectory of *base_directory*
650
+ as an existing module, then validates a second module carrying the
651
+ byte-identical body with scope deferred to the caller, so the returned message
652
+ is exactly the one the commit/push gate re-scopes. The destination is passed as
653
+ a neutral relative path with the sibling directory supplied explicitly, because
654
+ any test marker anywhere in the path exempts the file from the duplicate scan
655
+ and a pytest temporary directory carries one. The single duplicate-body
656
+ violation is returned for span assertions.
657
+
658
+ Args:
659
+ base_directory: A directory under which the sibling module directory is
660
+ created.
661
+
662
+ Returns:
663
+ The duplicate-body violation string the enforcer emits for the copy.
664
+ """
665
+ sibling_directory = base_directory / "blocking"
666
+ sibling_directory.mkdir()
667
+ (sibling_directory / "existing_helper.py").write_text(
668
+ _DUPLICATE_HELPER_SOURCE, encoding="utf-8"
669
+ )
670
+ duplicate_body_issues = check_duplicate_function_body_across_files(
671
+ _DUPLICATE_HELPER_SOURCE,
672
+ "blocking/copied_helper.py",
673
+ defer_scope_to_caller=True,
674
+ sibling_directory=sibling_directory,
675
+ )
676
+ matching_issues = [
677
+ each_issue
678
+ for each_issue in duplicate_body_issues
679
+ if "duplicates existing_helper.py" in each_issue
680
+ ]
681
+ assert matching_issues, f"expected a duplicate-body issue, got {duplicate_body_issues!r}"
682
+ return matching_issues[0]
683
+
684
+
685
+ def test_duplicate_body_span_range_covers_the_definition_through_last_body_line(
686
+ tmp_path: Path,
687
+ ) -> None:
688
+ """The reconstructed span starts at the copied function's definition line and
689
+ covers its full body, so a changed-line set intersects the span only when the
690
+ edit touches the duplicated function — mirroring the enforcer's own span."""
691
+ duplicate_body_issue = _duplicate_body_issue_for_copied_sibling(tmp_path)
692
+ definition_line = 3
693
+ last_body_line = 15
694
+ span = gate_module.duplicate_body_span_range(duplicate_body_issue)
695
+ assert span == range(definition_line, last_body_line + 1)
696
+
697
+
698
+ def test_split_violations_blocks_duplicate_body_when_span_intersects_added_lines(
699
+ tmp_path: Path,
700
+ ) -> None:
701
+ """A duplicate-body issue whose copied-function span overlaps the diff's added
702
+ lines is blocking — this commit introduced or touched the copy, exactly the
703
+ case the live Write/Edit hook flags."""
704
+ duplicate_body_issue = _duplicate_body_issue_for_copied_sibling(tmp_path)
705
+ inside_span_line = 4
706
+ blocking, advisory = gate_module.split_violations_by_scope(
707
+ [duplicate_body_issue],
708
+ all_added_line_numbers={inside_span_line},
709
+ )
710
+ assert blocking == [duplicate_body_issue]
711
+ assert advisory == []
712
+
713
+
714
+ def test_split_violations_advises_duplicate_body_when_span_misses_added_lines(
715
+ tmp_path: Path,
716
+ ) -> None:
717
+ """A duplicate-body issue for an untouched pre-existing copy — whose span does
718
+ not overlap any added line — is advisory, not blocking. Editing an unrelated
719
+ region of a file that already carries a sibling-duplicate helper must not
720
+ block the commit gate, matching the span-scoped Write/Edit behavior."""
721
+ duplicate_body_issue = _duplicate_body_issue_for_copied_sibling(tmp_path)
722
+ line_far_outside_span = 5000
723
+ blocking, advisory = gate_module.split_violations_by_scope(
724
+ [duplicate_body_issue],
725
+ all_added_line_numbers={line_far_outside_span},
726
+ )
727
+ assert advisory == [duplicate_body_issue]
728
+ assert blocking == []
729
+
730
+
731
+ def test_collect_partitioned_violations_advises_pre_existing_sibling_duplicate(
732
+ temporary_git_repository: Path,
733
+ ) -> None:
734
+ """A committed file already carrying a sibling-duplicate helper, edited only in
735
+ an unrelated region, yields the duplicate-body violation as advisory — never
736
+ blocking. Without a parseable span the gate forces every duplicate-body message
737
+ into the blocking payload, which would wedge a convergence loop the author
738
+ cannot clear by editing the touched lines.
739
+ """
740
+ package_directory = temporary_git_repository / "package"
741
+ package_directory.mkdir()
742
+ existing_file = package_directory / "existing_helper.py"
743
+ existing_file.write_text(_DUPLICATE_HELPER_SOURCE, encoding="utf-8")
744
+ copied_file = package_directory / "copied_helper.py"
745
+ copied_file.write_text(
746
+ _DUPLICATE_HELPER_SOURCE + "unrelated_constant = 1\n", encoding="utf-8"
747
+ )
748
+ unrelated_added_line = _DUPLICATE_HELPER_SOURCE.count("\n") + 1
749
+ validate_content = gate_module.load_validate_content()
750
+
751
+ resolved_copied = copied_file.resolve()
752
+ blocking_by_file, advisory_by_file, _skipped = (
753
+ gate_module._collect_partitioned_violations(
754
+ validate_content,
755
+ [copied_file],
756
+ temporary_git_repository,
757
+ {resolved_copied: {unrelated_added_line}},
758
+ )
759
+ )
760
+
761
+ all_blocking_messages = [
762
+ each_message
763
+ for each_file_messages in blocking_by_file.values()
764
+ for each_message in each_file_messages
765
+ ]
766
+ all_advisory_messages = [
767
+ each_message
768
+ for each_file_messages in advisory_by_file.values()
769
+ for each_message in each_file_messages
770
+ ]
771
+ assert not any(
772
+ "duplicates existing_helper.py" in each_message
773
+ for each_message in all_blocking_messages
774
+ ), (
775
+ "An unrelated edit to a file carrying a pre-existing sibling-duplicate "
776
+ f"helper must not block, got blocking: {all_blocking_messages}"
777
+ )
778
+ assert any(
779
+ "duplicates existing_helper.py" in each_message
780
+ for each_message in all_advisory_messages
781
+ ), (
782
+ "The untouched pre-existing duplicate must surface as advisory, got "
783
+ f"advisory: {all_advisory_messages}"
784
+ )
785
+
786
+
787
+ def test_collect_partitioned_violations_blocks_sibling_duplicate_in_added_region(
788
+ temporary_git_repository: Path,
789
+ ) -> None:
790
+ """When the diff's added lines fall inside the copied function, the duplicate
791
+ body is blocking — staging an edit that touches the copied helper still denies
792
+ the commit, matching the live Write/Edit hook."""
793
+ package_directory = temporary_git_repository / "package"
794
+ package_directory.mkdir()
795
+ existing_file = package_directory / "existing_helper.py"
796
+ existing_file.write_text(_DUPLICATE_HELPER_SOURCE, encoding="utf-8")
797
+ copied_file = package_directory / "copied_helper.py"
798
+ copied_file.write_text(_DUPLICATE_HELPER_SOURCE, encoding="utf-8")
799
+ definition_line = 3
800
+ last_body_line = 15
801
+ all_copied_function_lines = set(range(definition_line, last_body_line + 1))
802
+ validate_content = gate_module.load_validate_content()
803
+
804
+ resolved_copied = copied_file.resolve()
805
+ blocking_by_file, _advisory_by_file, _skipped = (
806
+ gate_module._collect_partitioned_violations(
807
+ validate_content,
808
+ [copied_file],
809
+ temporary_git_repository,
810
+ {resolved_copied: all_copied_function_lines},
811
+ )
812
+ )
813
+
814
+ all_blocking_messages = [
815
+ each_message
816
+ for each_file_messages in blocking_by_file.values()
817
+ for each_message in each_file_messages
818
+ ]
819
+ assert any(
820
+ "duplicates existing_helper.py" in each_message
821
+ for each_message in all_blocking_messages
822
+ ), (
823
+ "An edit whose added lines touch the copied helper must still block, got "
824
+ f"blocking: {all_blocking_messages}"
825
+ )
826
+
827
+
563
828
  def test_run_gate_skips_non_utf8_source_without_crashing(
564
829
  temporary_git_repository: Path,
565
830
  monkeypatch: pytest.MonkeyPatch,
@@ -65,6 +65,35 @@ def test_is_bugbot_disabled_via_env_true_when_both_tokens_listed_mixed_case(
65
65
  assert reviews_disabled.is_bugteam_disabled_via_env() is True
66
66
 
67
67
 
68
+ def test_is_copilot_disabled_via_env_returns_true_when_env_lists_copilot(
69
+ monkeypatch: pytest.MonkeyPatch,
70
+ ) -> None:
71
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
72
+ assert reviews_disabled.is_copilot_disabled_via_env() is True
73
+
74
+
75
+ def test_is_copilot_disabled_via_env_returns_false_when_env_is_empty(
76
+ monkeypatch: pytest.MonkeyPatch,
77
+ ) -> None:
78
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
79
+ assert reviews_disabled.is_copilot_disabled_via_env() is False
80
+
81
+
82
+ def test_is_copilot_disabled_via_env_returns_false_when_only_bugbot_listed(
83
+ monkeypatch: pytest.MonkeyPatch,
84
+ ) -> None:
85
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
86
+ assert reviews_disabled.is_copilot_disabled_via_env() is False
87
+
88
+
89
+ def test_is_copilot_disabled_via_env_true_when_listed_among_other_tokens(
90
+ monkeypatch: pytest.MonkeyPatch,
91
+ ) -> None:
92
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", " BugBot , CoPilot ")
93
+ assert reviews_disabled.is_copilot_disabled_via_env() is True
94
+ assert reviews_disabled.is_bugbot_disabled_via_env() is True
95
+
96
+
68
97
  def test_cli_main_returns_zero_when_named_reviewer_disabled(
69
98
  monkeypatch: pytest.MonkeyPatch,
70
99
  ) -> None:
@@ -25,7 +25,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
25
25
  | O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
26
26
  | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. |
27
27
  | O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
28
- | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. |
28
+ | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
 
31
31
  ---