claude-dev-env 1.58.0 → 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.
- package/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +13 -2
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -36
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- 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
|
|
49
|
-
2. **Verification** — when the coders finish, the main session spawns the `
|
|
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)
|
|
1056
|
-
enclosing-unit span fragment that ``enclosing_span_range``
|
|
1057
|
-
through one shared extractor registry; such a violation is
|
|
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
|
---
|
package/bin/install.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { join, dirname, resolve, relative } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { execSync, execFileSync } from 'node:child_process';
|
|
@@ -188,14 +188,53 @@ export function pythonCandidatesForPlatform(platform) {
|
|
|
188
188
|
return platform === 'win32' ? windowsOrder : defaultOrder;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Reports whether a resolved interpreter path belongs to the Microsoft Store
|
|
193
|
+
* Python, whose `python.exe` App Execution Alias reparse stub cannot be spawned
|
|
194
|
+
* as a hook subprocess. Both the alias under `Microsoft\WindowsApps` and the
|
|
195
|
+
* package executable under `Program Files\WindowsApps` sit beneath a
|
|
196
|
+
* `WindowsApps` directory, so the installer skips any candidate resolving there.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} executablePath Absolute interpreter path from sys.executable.
|
|
199
|
+
* @returns {boolean} True when the path lives under a WindowsApps directory.
|
|
200
|
+
*/
|
|
201
|
+
export function isWindowsStorePythonStub(executablePath) {
|
|
202
|
+
return /[\\/]windowsapps[\\/]/i.test(executablePath);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Formats an absolute interpreter path as a settings.json hook command prefix:
|
|
207
|
+
* forward-slash separators, double-quoted when the path contains a space so the
|
|
208
|
+
* harness parses the interpreter as a single argument.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} executablePath Absolute interpreter path from sys.executable.
|
|
211
|
+
* @returns {string} The command-prefix form of the interpreter path.
|
|
212
|
+
*/
|
|
213
|
+
export function interpreterCommandFromPath(executablePath) {
|
|
214
|
+
const forwardSlashedPath = executablePath.replace(/\\/g, '/');
|
|
215
|
+
return forwardSlashedPath.includes(' ') ? `"${forwardSlashedPath}"` : forwardSlashedPath;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Picks the interpreter command baked into every managed hook in settings.json.
|
|
220
|
+
* On win32 the first working candidate is resolved to its absolute
|
|
221
|
+
* sys.executable and that path is baked in, so a later PATH change or Microsoft
|
|
222
|
+
* Store update that re-points the `py`/`python` launcher cannot silently break
|
|
223
|
+
* the hooks; candidates resolving to the non-spawnable WindowsApps stub are
|
|
224
|
+
* skipped. Other platforms keep the bare command (e.g. `python3`).
|
|
225
|
+
*
|
|
226
|
+
* @returns {string|null} The interpreter command, or null when none is usable.
|
|
227
|
+
*/
|
|
191
228
|
function detectPython() {
|
|
192
229
|
const candidates = pythonCandidatesForPlatform(process.platform);
|
|
193
230
|
for (const { command, versionFlag } of candidates) {
|
|
194
231
|
try {
|
|
195
232
|
const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
196
|
-
if (version.includes('Python 3.'))
|
|
197
|
-
|
|
198
|
-
}
|
|
233
|
+
if (!version.includes('Python 3.')) continue;
|
|
234
|
+
if (process.platform !== 'win32') return command;
|
|
235
|
+
const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
236
|
+
if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
|
|
237
|
+
return interpreterCommandFromPath(executablePath);
|
|
199
238
|
} catch { /* try next */ }
|
|
200
239
|
}
|
|
201
240
|
return null;
|
|
@@ -501,7 +540,7 @@ function install(selectedGroups, options = {}) {
|
|
|
501
540
|
abortWhenPackageSourceHasConflicts(PACKAGE_ROOT);
|
|
502
541
|
const pythonCommand = detectPython();
|
|
503
542
|
if (!pythonCommand) {
|
|
504
|
-
console.error('ERROR: Python 3
|
|
543
|
+
console.error('ERROR: No usable Python 3 found. Install Python 3.8+ from python.org and ensure py, python3, or python is on PATH. On Windows the Microsoft Store python.exe alias is rejected because it cannot run hooks.');
|
|
505
544
|
process.exit(1);
|
|
506
545
|
}
|
|
507
546
|
console.log(` Python: ${pythonCommand}`);
|
|
@@ -815,28 +854,62 @@ writes the previous contents to ~/.claude/backups/CLAUDE.md.<timestamp>.bak firs
|
|
|
815
854
|
`);
|
|
816
855
|
}
|
|
817
856
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Reports whether this module is the process entry point (run as
|
|
859
|
+
* `node install.mjs`, or through a bin symlink such as the npm-installed
|
|
860
|
+
* `claude-dev-env` launcher) rather than imported by another module such as the
|
|
861
|
+
* test suite. The install/uninstall dispatch runs only when true, so importing
|
|
862
|
+
* the module carries no side effects.
|
|
863
|
+
*
|
|
864
|
+
* Both sides resolve to their real on-disk paths before comparison, so a
|
|
865
|
+
* symlinked launcher whose target is this module still counts as the entry
|
|
866
|
+
* point even though `process.argv[1]` keeps the symlink path while
|
|
867
|
+
* `import.meta.url` reports the resolved target. When either path cannot be
|
|
868
|
+
* resolved on disk (for example a synthetic path in a unit test), the raw
|
|
869
|
+
* paths are compared instead.
|
|
870
|
+
*
|
|
871
|
+
* @param {string} moduleUrl The module's import.meta.url.
|
|
872
|
+
* @param {string|undefined} entryScriptPath The invoked script path (process.argv[1]).
|
|
873
|
+
* @returns {boolean} True when the module is the process entry point.
|
|
874
|
+
*/
|
|
875
|
+
export function invokedAsEntryPoint(moduleUrl, entryScriptPath) {
|
|
876
|
+
if (!entryScriptPath) return false;
|
|
877
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
878
|
+
return realPathOrSelf(modulePath) === realPathOrSelf(entryScriptPath);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function realPathOrSelf(filesystemPath) {
|
|
882
|
+
try {
|
|
883
|
+
return realpathSync(filesystemPath);
|
|
884
|
+
} catch {
|
|
885
|
+
return filesystemPath;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
|
|
890
|
+
const rawArgs = process.argv.slice(2);
|
|
891
|
+
const args = rawArgs.filter((flag) => flag !== '--update');
|
|
892
|
+
const isUpdateRefresh = rawArgs.includes('--update');
|
|
893
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
894
|
+
printHelp();
|
|
895
|
+
} else if (args.includes('--uninstall')) {
|
|
896
|
+
uninstall();
|
|
897
|
+
} else {
|
|
898
|
+
const onlyIndex = args.indexOf('--only');
|
|
899
|
+
let selectedGroups = null;
|
|
900
|
+
if (onlyIndex !== -1) {
|
|
901
|
+
const onlyValue = args[onlyIndex + 1];
|
|
902
|
+
if (!onlyValue || onlyValue.startsWith('--')) {
|
|
903
|
+
console.error(`ERROR: --only requires a comma-separated list of groups.\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
|
|
904
|
+
process.exit(1);
|
|
905
|
+
}
|
|
906
|
+
selectedGroups = onlyValue.split(',').map(name => name.trim());
|
|
907
|
+
const invalidGroups = selectedGroups.filter(name => !INSTALL_GROUPS[name]);
|
|
908
|
+
if (invalidGroups.length > 0) {
|
|
909
|
+
console.error(`ERROR: Unknown group(s): ${invalidGroups.join(', ')}\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
|
|
910
|
+
process.exit(1);
|
|
911
|
+
}
|
|
839
912
|
}
|
|
913
|
+
install(selectedGroups, { isUpdateRefresh });
|
|
840
914
|
}
|
|
841
|
-
install(selectedGroups, { isUpdateRefresh });
|
|
842
915
|
}
|