claude-dev-env 1.37.1 → 1.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -0
- package/_shared/pr-loop/audit-contract.md +4 -3
- package/_shared/pr-loop/fix-protocol.md +2 -0
- package/_shared/pr-loop/gh-payloads.md +38 -37
- package/_shared/pr-loop/scripts/README.md +0 -1
- package/_shared/pr-loop/scripts/preflight.py +2 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/tests/test_preflight.py +22 -0
- package/_shared/pr-loop/state-schema.md +10 -10
- package/agents/clean-coder.md +4 -0
- package/agents/code-quality-agent.md +23 -85
- package/agents/groq-coder.md +8 -6
- package/hooks/blocking/__init__.py +0 -0
- package/hooks/blocking/code_rules_enforcer.py +93 -32
- package/hooks/blocking/hedging_language_blocker.py +2 -2
- package/hooks/blocking/state_description_blocker.py +243 -0
- package/hooks/blocking/tdd_enforcer.py +94 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +158 -0
- package/hooks/blocking/test_hedging_language_blocker.py +1 -1
- package/hooks/blocking/test_state_description_blocker.py +618 -0
- package/hooks/blocking/test_tdd_enforcer.py +152 -0
- package/hooks/config/state_description_blocker_constants.py +130 -0
- package/hooks/hooks.json +10 -0
- package/package.json +1 -1
- package/rules/no-historical-clutter.md +31 -10
- package/scripts/config/groq_bugteam_config.py +13 -5
- package/skills/bugteam/CONSTRAINTS.md +20 -27
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/PROMPTS.md +60 -31
- package/skills/bugteam/SKILL.md +47 -47
- package/skills/bugteam/SKILL_EVALS.md +8 -8
- package/skills/bugteam/reference/github-pr-reviews.md +31 -31
- package/skills/bugteam/reference/team-setup.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +4 -4
- package/skills/copilot-review/SKILL.md +7 -14
- package/skills/findbugs/SKILL.md +2 -2
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -6
- package/skills/pr-converge/SKILL.md +7 -6
- package/skills/pr-converge/reference/convergence-gates.md +28 -30
- package/skills/pr-converge/reference/examples.md +4 -4
- package/skills/pr-converge/reference/fix-protocol.md +6 -8
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -10
- package/skills/pr-converge/reference/per-tick.md +18 -33
- package/skills/pr-converge/reference/stop-conditions.md +7 -7
- package/skills/pr-converge/scripts/README.md +65 -117
- package/skills/pr-review-responder/EXAMPLES.md +2 -2
- package/skills/pr-review-responder/PRINCIPLES.md +2 -8
- package/skills/pr-review-responder/README.md +7 -48
- package/skills/pr-review-responder/SKILL.md +2 -3
- package/skills/pr-review-responder/TESTING.md +8 -65
- package/skills/qbug/SKILL.md +10 -16
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +0 -31
- package/_shared/pr-loop/scripts/gh_util.py +0 -193
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +0 -257
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +0 -61
- package/skills/pr-converge/scripts/check_pr_mergeability.py +0 -78
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +0 -134
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -152
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +0 -57
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +0 -61
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +0 -61
- package/skills/pr-converge/scripts/mark_pr_ready.py +0 -54
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +0 -49
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +0 -33
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +0 -84
- package/skills/pr-converge/scripts/request_copilot_review.py +0 -71
- package/skills/pr-converge/scripts/resolve_pr_head.py +0 -58
- package/skills/pr-converge/scripts/review_field_helpers.py +0 -43
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +0 -153
- package/skills/pr-converge/scripts/reviewer_specs.py +0 -98
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +0 -126
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +0 -443
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +0 -299
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +0 -485
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +0 -368
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +0 -440
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +0 -366
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +0 -69
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +0 -195
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +0 -159
- package/skills/pr-converge/scripts/test_request_copilot_review.py +0 -101
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +0 -79
- package/skills/pr-converge/scripts/test_review_field_helpers.py +0 -80
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +0 -448
- package/skills/pr-converge/scripts/test_reviewer_specs.py +0 -107
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +0 -139
- package/skills/pr-converge/scripts/test_view_pr_context.py +0 -155
- package/skills/pr-converge/scripts/trigger_bugbot.py +0 -77
- package/skills/pr-converge/scripts/view_pr_context.py +0 -78
- package/skills/pr-review-responder/scripts/respond_to_reviews.py +0 -376
|
@@ -2580,7 +2580,30 @@ def _collect_load_names_outside_import_ranges(
|
|
|
2580
2580
|
return referenced_names
|
|
2581
2581
|
|
|
2582
2582
|
|
|
2583
|
-
def
|
|
2583
|
+
def _module_declares_dunder_all(tree: ast.Module) -> bool:
|
|
2584
|
+
"""Return True when the module body assigns or annotates ``__all__``."""
|
|
2585
|
+
return any(
|
|
2586
|
+
(
|
|
2587
|
+
isinstance(each_node, ast.Assign)
|
|
2588
|
+
and any(
|
|
2589
|
+
isinstance(each_target, ast.Name) and each_target.id == "__all__"
|
|
2590
|
+
for each_target in each_node.targets
|
|
2591
|
+
)
|
|
2592
|
+
)
|
|
2593
|
+
or (
|
|
2594
|
+
isinstance(each_node, ast.AnnAssign)
|
|
2595
|
+
and isinstance(each_node.target, ast.Name)
|
|
2596
|
+
and each_node.target.id == "__all__"
|
|
2597
|
+
)
|
|
2598
|
+
for each_node in tree.body
|
|
2599
|
+
)
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def check_unused_module_level_imports(
|
|
2603
|
+
content: str,
|
|
2604
|
+
file_path: str,
|
|
2605
|
+
full_file_content: str | None = None,
|
|
2606
|
+
) -> list[str]:
|
|
2584
2607
|
"""Flag module-level imports that are never referenced in the rest of the file.
|
|
2585
2608
|
|
|
2586
2609
|
References are detected from AST ``Name`` / ``Attribute`` loads outside import
|
|
@@ -2589,42 +2612,39 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
|
|
|
2589
2612
|
whose module body includes ``if TYPE_CHECKING:`` (or
|
|
2590
2613
|
``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
|
|
2591
2614
|
``# noqa`` or an explicit ``F401`` code in the noqa list only.
|
|
2615
|
+
|
|
2616
|
+
When ``full_file_content`` is provided, ``content`` is treated as an Edit
|
|
2617
|
+
fragment containing the imports being added or replaced, while the
|
|
2618
|
+
``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
|
|
2619
|
+
against ``full_file_content`` (the post-edit file as it will look once the
|
|
2620
|
+
Edit applies). This prevents false-positive flags on imports added in the
|
|
2621
|
+
same Edit as their consumers.
|
|
2592
2622
|
"""
|
|
2593
2623
|
if is_test_file(file_path):
|
|
2594
2624
|
return []
|
|
2595
2625
|
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2596
2626
|
return []
|
|
2597
2627
|
try:
|
|
2598
|
-
|
|
2628
|
+
fragment_tree = ast.parse(content)
|
|
2599
2629
|
except SyntaxError:
|
|
2600
2630
|
return []
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
)
|
|
2608
|
-
)
|
|
2609
|
-
or (
|
|
2610
|
-
isinstance(each_node, ast.AnnAssign)
|
|
2611
|
-
and isinstance(each_node.target, ast.Name)
|
|
2612
|
-
and each_node.target.id == "__all__"
|
|
2613
|
-
)
|
|
2614
|
-
for each_node in tree.body
|
|
2615
|
-
)
|
|
2616
|
-
if file_declares_dunder_all:
|
|
2631
|
+
reference_source = full_file_content if full_file_content is not None else content
|
|
2632
|
+
try:
|
|
2633
|
+
reference_tree = ast.parse(reference_source)
|
|
2634
|
+
except SyntaxError:
|
|
2635
|
+
return []
|
|
2636
|
+
if _module_declares_dunder_all(reference_tree):
|
|
2617
2637
|
return []
|
|
2618
|
-
if _module_body_declares_type_checking_gate(
|
|
2638
|
+
if _module_body_declares_type_checking_gate(reference_tree):
|
|
2619
2639
|
return []
|
|
2620
|
-
|
|
2621
|
-
|
|
2640
|
+
fragment_lines = content.splitlines()
|
|
2641
|
+
reference_import_ranges = _import_statement_line_ranges(reference_tree)
|
|
2622
2642
|
referenced_names = _collect_load_names_outside_import_ranges(
|
|
2623
|
-
|
|
2624
|
-
|
|
2643
|
+
reference_tree,
|
|
2644
|
+
reference_import_ranges,
|
|
2625
2645
|
)
|
|
2626
2646
|
import_bindings: list[tuple[str, int, int | None]] = []
|
|
2627
|
-
for each_node in
|
|
2647
|
+
for each_node in fragment_tree.body:
|
|
2628
2648
|
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
2629
2649
|
if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
|
|
2630
2650
|
continue
|
|
@@ -2632,14 +2652,14 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
|
|
|
2632
2652
|
import_bindings.append(each_binding)
|
|
2633
2653
|
issues: list[str] = []
|
|
2634
2654
|
for each_name, each_line_number, each_from_keyword_line in import_bindings:
|
|
2635
|
-
if 1 <= each_line_number <= len(
|
|
2636
|
-
if line_suppresses_unused_import_via_noqa(
|
|
2655
|
+
if 1 <= each_line_number <= len(fragment_lines):
|
|
2656
|
+
if line_suppresses_unused_import_via_noqa(fragment_lines[each_line_number - 1]):
|
|
2637
2657
|
continue
|
|
2638
2658
|
if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
|
|
2639
|
-
|
|
2659
|
+
fragment_lines
|
|
2640
2660
|
):
|
|
2641
2661
|
if line_suppresses_unused_import_via_noqa(
|
|
2642
|
-
|
|
2662
|
+
fragment_lines[each_from_keyword_line - 1]
|
|
2643
2663
|
):
|
|
2644
2664
|
continue
|
|
2645
2665
|
if each_name in referenced_names:
|
|
@@ -2904,14 +2924,25 @@ def check_return_annotations(content: str, file_path: str) -> list[str]:
|
|
|
2904
2924
|
return issues
|
|
2905
2925
|
|
|
2906
2926
|
|
|
2907
|
-
def validate_content(
|
|
2927
|
+
def validate_content(
|
|
2928
|
+
content: str,
|
|
2929
|
+
file_path: str,
|
|
2930
|
+
old_content: str = "",
|
|
2931
|
+
full_file_content: str | None = None,
|
|
2932
|
+
) -> list[str]:
|
|
2908
2933
|
"""Run all applicable validators on content.
|
|
2909
2934
|
|
|
2910
2935
|
Args:
|
|
2911
|
-
content: The new content being written.
|
|
2936
|
+
content: The new content being written. For Edit, this is the
|
|
2937
|
+
``new_string`` fragment; for Write, the entire new file body.
|
|
2912
2938
|
file_path: Path to the file.
|
|
2913
2939
|
old_content: Previous content (old_string for Edit, existing file for Write).
|
|
2914
2940
|
Used to detect comment additions/removals instead of flagging all comments.
|
|
2941
|
+
full_file_content: For Edit operations, the reconstructed post-edit
|
|
2942
|
+
content of the entire file (existing file with ``old_string`` replaced
|
|
2943
|
+
by ``new_string``). Whole-file checks such as the unused-import
|
|
2944
|
+
scanner use this to evaluate references across the file rather than
|
|
2945
|
+
just within the inserted fragment.
|
|
2915
2946
|
"""
|
|
2916
2947
|
extension = get_file_extension(file_path)
|
|
2917
2948
|
all_issues = []
|
|
@@ -2938,7 +2969,9 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
2938
2969
|
all_issues.extend(check_stuttering_collection_prefix(content, file_path))
|
|
2939
2970
|
all_issues.extend(check_hardcoded_user_paths(content, file_path))
|
|
2940
2971
|
all_issues.extend(check_sys_path_insert_deduplication_guard(content, file_path))
|
|
2941
|
-
all_issues.extend(
|
|
2972
|
+
all_issues.extend(
|
|
2973
|
+
check_unused_module_level_imports(content, file_path, full_file_content)
|
|
2974
|
+
)
|
|
2942
2975
|
all_issues.extend(check_library_print(content, file_path))
|
|
2943
2976
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
2944
2977
|
all_issues.extend(check_return_annotations(content, file_path))
|
|
@@ -2959,6 +2992,30 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
2959
2992
|
return all_issues
|
|
2960
2993
|
|
|
2961
2994
|
|
|
2995
|
+
def _reconstruct_post_edit_file_content(
|
|
2996
|
+
file_path: str, old_string: str, new_string: str,
|
|
2997
|
+
) -> str | None:
|
|
2998
|
+
"""Return the file content as it will look after the Edit applies, or None.
|
|
2999
|
+
|
|
3000
|
+
Reads ``file_path`` from disk and replaces the first occurrence of
|
|
3001
|
+
``old_string`` with ``new_string``, mirroring how the Edit tool itself
|
|
3002
|
+
applies a single replacement. Returns None when the file cannot be read,
|
|
3003
|
+
``old_string`` is empty, or ``old_string`` is not present in the existing
|
|
3004
|
+
file (which means the Edit will fail or has already been applied — neither
|
|
3005
|
+
case yields a well-defined post-edit view).
|
|
3006
|
+
"""
|
|
3007
|
+
if not old_string:
|
|
3008
|
+
return None
|
|
3009
|
+
try:
|
|
3010
|
+
with open(file_path, "r", encoding="utf-8") as existing_file:
|
|
3011
|
+
existing_content = existing_file.read()
|
|
3012
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
3013
|
+
return None
|
|
3014
|
+
if old_string not in existing_content:
|
|
3015
|
+
return None
|
|
3016
|
+
return existing_content.replace(old_string, new_string, 1)
|
|
3017
|
+
|
|
3018
|
+
|
|
2962
3019
|
def main() -> None:
|
|
2963
3020
|
try:
|
|
2964
3021
|
input_data = json.load(sys.stdin)
|
|
@@ -2980,9 +3037,13 @@ def main() -> None:
|
|
|
2980
3037
|
sys.exit(0)
|
|
2981
3038
|
|
|
2982
3039
|
old_content = ""
|
|
3040
|
+
full_file_content_after_edit: str | None = None
|
|
2983
3041
|
if tool_name == "Edit":
|
|
2984
3042
|
content = tool_input.get("new_string", "")
|
|
2985
3043
|
old_content = tool_input.get("old_string", "")
|
|
3044
|
+
full_file_content_after_edit = _reconstruct_post_edit_file_content(
|
|
3045
|
+
file_path, old_content, content,
|
|
3046
|
+
)
|
|
2986
3047
|
else:
|
|
2987
3048
|
content = tool_input.get("content", "") or tool_input.get("new_string", "")
|
|
2988
3049
|
try:
|
|
@@ -2997,7 +3058,7 @@ def main() -> None:
|
|
|
2997
3058
|
if not content:
|
|
2998
3059
|
sys.exit(0)
|
|
2999
3060
|
|
|
3000
|
-
issues = validate_content(content, file_path, old_content)
|
|
3061
|
+
issues = validate_content(content, file_path, old_content, full_file_content_after_edit)
|
|
3001
3062
|
|
|
3002
3063
|
if issues:
|
|
3003
3064
|
issue_list = "; ".join(issues[:10])
|
|
@@ -123,7 +123,7 @@ def main() -> None:
|
|
|
123
123
|
else:
|
|
124
124
|
skill_reference = (
|
|
125
125
|
"under research-mode constraints "
|
|
126
|
-
"(no research-mode skill installed; verify with sources or
|
|
126
|
+
"(no research-mode skill installed; verify with sources or prompt the user via AskUserQuestion with potential options + context)"
|
|
127
127
|
)
|
|
128
128
|
|
|
129
129
|
block_response = {
|
|
@@ -134,7 +134,7 @@ def main() -> None:
|
|
|
134
134
|
f"These words signal unverified claims. You MUST rewrite your response "
|
|
135
135
|
f"{skill_reference}\n\n"
|
|
136
136
|
f"Do NOT simply remove the hedging word and keep the unverified claim. "
|
|
137
|
-
f"
|
|
137
|
+
f"Do more research to VERIFY it with a source, or prompt the user via AskUserQuestion with some potential options + context if you are unable to find anything online.\n\n"
|
|
138
138
|
f"You MUST re-output the complete, revised response with the corrections applied."
|
|
139
139
|
),
|
|
140
140
|
"systemMessage": USER_FACING_NOTICE,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks Write/Edit containing historical/comparative language in comments and .md files.
|
|
3
|
+
|
|
4
|
+
Enforces the "describe current state only" rule — no "instead of", "previously",
|
|
5
|
+
"now uses", or similar transitional framing. Comments and documentation should
|
|
6
|
+
describe what IS, not what WAS or what CHANGED.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
17
|
+
hooks_tree = Path(__file__).absolute().parent.parent
|
|
18
|
+
hooks_tree_string = str(hooks_tree)
|
|
19
|
+
if hooks_tree_string not in sys.path:
|
|
20
|
+
sys.path.insert(0, hooks_tree_string)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_insert_hooks_tree_for_imports()
|
|
24
|
+
|
|
25
|
+
from config.state_description_blocker_constants import (
|
|
26
|
+
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
27
|
+
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
28
|
+
ALL_COMMENT_BEARING_EXTENSIONS,
|
|
29
|
+
ALL_COMMENT_TRANSITION_PATTERNS,
|
|
30
|
+
ALL_HASH_AND_SLASH_EXTENSIONS,
|
|
31
|
+
ALL_HASH_ONLY_EXTENSIONS,
|
|
32
|
+
ALL_MARKDOWN_EXTENSIONS,
|
|
33
|
+
CODE_FENCE_PATTERN,
|
|
34
|
+
INLINE_CODE_PATTERN,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_file_extension(file_path: str) -> str:
|
|
39
|
+
_, extension = os.path.splitext(file_path)
|
|
40
|
+
return extension.lower()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_markdown_file(file_path: str) -> bool:
|
|
44
|
+
return _get_file_extension(file_path) in ALL_MARKDOWN_EXTENSIONS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_comment_bearing_file(file_path: str) -> bool:
|
|
48
|
+
return _get_file_extension(file_path) in ALL_COMMENT_BEARING_EXTENSIONS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_inline_markers(extension: str) -> tuple[str, ...]:
|
|
52
|
+
if extension in ALL_HASH_ONLY_EXTENSIONS:
|
|
53
|
+
return ("#",)
|
|
54
|
+
if extension in ALL_HASH_AND_SLASH_EXTENSIONS:
|
|
55
|
+
return ("#", "//")
|
|
56
|
+
if extension in ALL_BLOCK_COMMENT_ONLY_EXTENSIONS:
|
|
57
|
+
return ()
|
|
58
|
+
return ("//",)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_comment_lines(text: str, extension: str = "") -> list[str]:
|
|
62
|
+
"""Extract comment lines from source code — Python (#), JS/TS/C/Rust/Go (//), and block comments."""
|
|
63
|
+
all_comment_lines: list[str] = []
|
|
64
|
+
all_lines = text.splitlines()
|
|
65
|
+
|
|
66
|
+
is_in_block_comment = False
|
|
67
|
+
has_block_comments = extension in ALL_BLOCK_COMMENT_EXTENSIONS
|
|
68
|
+
all_inline_markers = _get_inline_markers(extension)
|
|
69
|
+
for each_line in all_lines:
|
|
70
|
+
stripped = each_line.strip()
|
|
71
|
+
|
|
72
|
+
if has_block_comments:
|
|
73
|
+
if any(
|
|
74
|
+
stripped.startswith(each_marker)
|
|
75
|
+
for each_marker in all_inline_markers
|
|
76
|
+
):
|
|
77
|
+
all_comment_lines.append(stripped)
|
|
78
|
+
continue
|
|
79
|
+
if "/*" in stripped and not is_in_block_comment:
|
|
80
|
+
is_in_block_comment = True
|
|
81
|
+
slash_star_index = stripped.find("/*")
|
|
82
|
+
close_star_index = stripped.find("*/", slash_star_index + len("/*"))
|
|
83
|
+
if close_star_index >= 0:
|
|
84
|
+
all_comment_lines.append(
|
|
85
|
+
stripped[slash_star_index : close_star_index + 2]
|
|
86
|
+
)
|
|
87
|
+
is_in_block_comment = False
|
|
88
|
+
after_close = stripped[close_star_index + 2:].lstrip()
|
|
89
|
+
if not after_close:
|
|
90
|
+
continue
|
|
91
|
+
stripped = after_close
|
|
92
|
+
else:
|
|
93
|
+
all_comment_lines.append(stripped[slash_star_index:])
|
|
94
|
+
continue
|
|
95
|
+
if is_in_block_comment:
|
|
96
|
+
close_index = stripped.find("*/")
|
|
97
|
+
if close_index >= 0:
|
|
98
|
+
all_comment_lines.append(stripped[: close_index + 2])
|
|
99
|
+
is_in_block_comment = False
|
|
100
|
+
else:
|
|
101
|
+
all_comment_lines.append(stripped)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if any(
|
|
105
|
+
stripped.startswith(each_marker) for each_marker in all_inline_markers
|
|
106
|
+
):
|
|
107
|
+
all_comment_lines.append(stripped)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
inline_index = _find_inline_comment_start(stripped, all_inline_markers)
|
|
111
|
+
if inline_index is not None and inline_index > 0:
|
|
112
|
+
all_comment_lines.append(stripped[inline_index:])
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
return all_comment_lines
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> int | None:
|
|
119
|
+
"""Find the earliest inline comment marker in a code line, across all markers.
|
|
120
|
+
Skips // when preceded by : to avoid treating URLs as inline comments,
|
|
121
|
+
but continues searching for subsequent // that are actual comments."""
|
|
122
|
+
best_position: int | None = None
|
|
123
|
+
for each_marker in all_markers:
|
|
124
|
+
search_start = 0
|
|
125
|
+
while True:
|
|
126
|
+
position = stripped.find(each_marker, search_start)
|
|
127
|
+
if position <= 0:
|
|
128
|
+
break
|
|
129
|
+
if each_marker == "//" and stripped[position - 1] == ":":
|
|
130
|
+
search_start = position + 1
|
|
131
|
+
continue
|
|
132
|
+
if best_position is None or position < best_position:
|
|
133
|
+
best_position = position
|
|
134
|
+
break
|
|
135
|
+
return best_position
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def find_violations(text: str, file_path: str) -> list[str]:
|
|
139
|
+
"""Return all violated patterns found in text for the given file.
|
|
140
|
+
|
|
141
|
+
For .md files, scans the entire text. For code files, scans only comment lines.
|
|
142
|
+
Returns a list of matched pattern source strings.
|
|
143
|
+
"""
|
|
144
|
+
if is_markdown_file(file_path):
|
|
145
|
+
scan_text = text
|
|
146
|
+
elif is_comment_bearing_file(file_path):
|
|
147
|
+
all_comment_lines = _extract_comment_lines(text, _get_file_extension(file_path))
|
|
148
|
+
scan_text = "\n".join(all_comment_lines)
|
|
149
|
+
else:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
if is_markdown_file(file_path):
|
|
153
|
+
scan_text = CODE_FENCE_PATTERN.sub("", scan_text)
|
|
154
|
+
scan_text = INLINE_CODE_PATTERN.sub("", scan_text)
|
|
155
|
+
|
|
156
|
+
if not scan_text.strip():
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
all_detected: list[str] = []
|
|
160
|
+
all_transition_patterns = ALL_COMMENT_TRANSITION_PATTERNS
|
|
161
|
+
for each_pattern in all_transition_patterns:
|
|
162
|
+
all_matches = each_pattern.findall(scan_text)
|
|
163
|
+
if all_matches:
|
|
164
|
+
all_detected.append(all_matches[0].strip().lower())
|
|
165
|
+
|
|
166
|
+
return all_detected
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
try:
|
|
171
|
+
input_data = json.load(sys.stdin)
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
sys.exit(0)
|
|
174
|
+
|
|
175
|
+
if not isinstance(input_data, dict):
|
|
176
|
+
sys.exit(0)
|
|
177
|
+
|
|
178
|
+
tool_name = input_data.get("tool_name", "")
|
|
179
|
+
if not isinstance(tool_name, str):
|
|
180
|
+
sys.exit(0)
|
|
181
|
+
|
|
182
|
+
tool_input = input_data.get("tool_input", {})
|
|
183
|
+
if not isinstance(tool_input, dict):
|
|
184
|
+
sys.exit(0)
|
|
185
|
+
|
|
186
|
+
if tool_name not in ("Write", "Edit"):
|
|
187
|
+
sys.exit(0)
|
|
188
|
+
|
|
189
|
+
file_path = tool_input.get("file_path", "")
|
|
190
|
+
if not file_path or not (
|
|
191
|
+
is_markdown_file(file_path) or is_comment_bearing_file(file_path)
|
|
192
|
+
):
|
|
193
|
+
sys.exit(0)
|
|
194
|
+
|
|
195
|
+
content_to_check = ""
|
|
196
|
+
if tool_name == "Write":
|
|
197
|
+
content_to_check = tool_input.get("content", "")
|
|
198
|
+
elif tool_name == "Edit":
|
|
199
|
+
content_to_check = tool_input.get("new_string", "")
|
|
200
|
+
|
|
201
|
+
if not content_to_check:
|
|
202
|
+
sys.exit(0)
|
|
203
|
+
|
|
204
|
+
all_detected_patterns = find_violations(content_to_check, file_path)
|
|
205
|
+
if not all_detected_patterns:
|
|
206
|
+
sys.exit(0)
|
|
207
|
+
|
|
208
|
+
formatted = ", ".join(f'"{p}"' for p in all_detected_patterns)
|
|
209
|
+
|
|
210
|
+
block_payload = {
|
|
211
|
+
"hookSpecificOutput": {
|
|
212
|
+
"hookEventName": "PreToolUse",
|
|
213
|
+
"permissionDecision": "deny",
|
|
214
|
+
"permissionDecisionReason": (
|
|
215
|
+
f"Historical/comparative language detected in {file_path}: "
|
|
216
|
+
f"{formatted}. Describe current state only — no 'instead of', "
|
|
217
|
+
f"'previously', 'now uses', etc. The git log tracks what changed. "
|
|
218
|
+
f"Comments and docs describe what IS."
|
|
219
|
+
),
|
|
220
|
+
"additionalContext": (
|
|
221
|
+
"Rewrite the affected comments or documentation to describe "
|
|
222
|
+
"only the current state. For example:\n"
|
|
223
|
+
' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
|
|
224
|
+
' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
|
|
225
|
+
"See ~/.claude/rules/no-historical-clutter.md for full rules."
|
|
226
|
+
),
|
|
227
|
+
},
|
|
228
|
+
"systemMessage": "Agent wrote comparative/historical language - describe current state only",
|
|
229
|
+
"suppressOutput": True,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
233
|
+
sys.exit(0)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _emit_hook_result(all_hook_data: dict, output_stream: io.TextIOBase) -> None:
|
|
237
|
+
"""Write the hook result JSON to the given output stream."""
|
|
238
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
239
|
+
output_stream.flush()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import time
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
|
|
16
17
|
_hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
|
|
17
18
|
if _hooks_root_path_string not in sys.path:
|
|
18
19
|
sys.path.insert(0, _hooks_root_path_string)
|
|
@@ -58,6 +59,48 @@ def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
|
|
|
58
59
|
return isinstance(expression_value.value, str)
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def _safe_constant_functions() -> frozenset[str]:
|
|
63
|
+
"""Unqualified function names treated as safe value constructors."""
|
|
64
|
+
return frozenset({"Path", "frozenset"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
|
|
68
|
+
"""(module, attr) pairs treated as safe value constructors."""
|
|
69
|
+
return frozenset({("re", "compile")})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
|
|
73
|
+
"""Return True when rhs_node contains a function call outside the safe allowlist.
|
|
74
|
+
|
|
75
|
+
Safe calls are value constructors (``Path(...)``, ``re.compile(...)``)
|
|
76
|
+
that create objects without side effects. Any other call pattern is
|
|
77
|
+
treated as unsafe import-time behavior.
|
|
78
|
+
"""
|
|
79
|
+
safe_functions = _safe_constant_functions()
|
|
80
|
+
safe_attribute_calls = _safe_constant_attribute_calls()
|
|
81
|
+
for each_subnode in ast.walk(rhs_node):
|
|
82
|
+
if isinstance(each_subnode, ast.Call):
|
|
83
|
+
function_node = each_subnode.func
|
|
84
|
+
if isinstance(function_node, ast.Name):
|
|
85
|
+
if function_node.id not in safe_functions:
|
|
86
|
+
return True
|
|
87
|
+
elif isinstance(function_node, ast.Attribute):
|
|
88
|
+
if isinstance(function_node.value, ast.Name):
|
|
89
|
+
pair = (function_node.value.id, function_node.attr)
|
|
90
|
+
if pair not in safe_attribute_calls:
|
|
91
|
+
return True
|
|
92
|
+
else:
|
|
93
|
+
return True
|
|
94
|
+
else:
|
|
95
|
+
return True
|
|
96
|
+
elif isinstance(
|
|
97
|
+
each_subnode,
|
|
98
|
+
(ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda),
|
|
99
|
+
):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
61
104
|
def _is_constants_only_python_content(content: str) -> bool:
|
|
62
105
|
if not content.strip():
|
|
63
106
|
return False
|
|
@@ -70,6 +113,10 @@ def _is_constants_only_python_content(content: str) -> bool:
|
|
|
70
113
|
allowed_node_types = _constants_only_allowed_node_types()
|
|
71
114
|
for each_top_level_node in parsed_tree.body:
|
|
72
115
|
if isinstance(each_top_level_node, allowed_node_types):
|
|
116
|
+
if isinstance(each_top_level_node, (ast.Assign, ast.AnnAssign)):
|
|
117
|
+
rhs = each_top_level_node.value
|
|
118
|
+
if rhs is not None and _rhs_has_unsafe_call(rhs):
|
|
119
|
+
return False
|
|
73
120
|
continue
|
|
74
121
|
if _is_module_docstring_expression(each_top_level_node):
|
|
75
122
|
continue
|
|
@@ -77,6 +124,44 @@ def _is_constants_only_python_content(content: str) -> bool:
|
|
|
77
124
|
return True
|
|
78
125
|
|
|
79
126
|
|
|
127
|
+
def _apply_edit_to_content(existing_content: str, old_str: str, new_str: str) -> str:
|
|
128
|
+
"""Replace the first occurrence of old_str with new_str in the content."""
|
|
129
|
+
return existing_content.replace(old_str, new_str, 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
133
|
+
"""Check if post-edit content remains constants-only after Edit or MultiEdit.
|
|
134
|
+
|
|
135
|
+
Both the existing content and the post-edit result must be constants-only
|
|
136
|
+
to prevent edits on files with behavior from bypassing the TDD gate.
|
|
137
|
+
"""
|
|
138
|
+
if not _is_constants_only_python_content(existing_content):
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
if tool_name == "Edit":
|
|
142
|
+
old_str = tool_input.get("old_string", "")
|
|
143
|
+
new_str = tool_input.get("new_string", "") or ""
|
|
144
|
+
if not old_str:
|
|
145
|
+
return False
|
|
146
|
+
post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str)
|
|
147
|
+
return _is_constants_only_python_content(post_edit_content)
|
|
148
|
+
|
|
149
|
+
if tool_name == "MultiEdit":
|
|
150
|
+
all_edits = tool_input.get("edits", []) or []
|
|
151
|
+
post_edit_content = existing_content
|
|
152
|
+
for each_edit in all_edits:
|
|
153
|
+
if not isinstance(each_edit, dict):
|
|
154
|
+
return False
|
|
155
|
+
each_old = each_edit.get("old_string", "")
|
|
156
|
+
each_new = each_edit.get("new_string", "") or ""
|
|
157
|
+
if not each_old:
|
|
158
|
+
return False
|
|
159
|
+
post_edit_content = _apply_edit_to_content(post_edit_content, each_old, each_new)
|
|
160
|
+
return _is_constants_only_python_content(post_edit_content)
|
|
161
|
+
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
80
165
|
def _tests_directory_name() -> str:
|
|
81
166
|
return "tests"
|
|
82
167
|
|
|
@@ -297,11 +382,20 @@ def main() -> None:
|
|
|
297
382
|
sys.exit(0)
|
|
298
383
|
|
|
299
384
|
# Block production code - require confirmation
|
|
385
|
+
# Exempt constants-only content for Write (full content provided)
|
|
300
386
|
written_content = _extract_written_content(tool_name, tool_input)
|
|
301
387
|
if tool_name == "Write" and ext == ".py" and _is_constants_only_python_content(written_content):
|
|
302
388
|
emit_allow()
|
|
303
389
|
sys.exit(0)
|
|
304
390
|
|
|
391
|
+
# Exempt Edit/MultiEdit on constants-only files when post-edit content remains constants-only
|
|
392
|
+
if tool_name in ("Edit", "MultiEdit") and ext == ".py" and path.exists():
|
|
393
|
+
existing_content = _read_candidate_text(path)
|
|
394
|
+
if existing_content is not None:
|
|
395
|
+
if _is_post_edit_constants_only(existing_content, tool_name, tool_input):
|
|
396
|
+
emit_allow()
|
|
397
|
+
sys.exit(0)
|
|
398
|
+
|
|
305
399
|
all_candidates = candidate_test_paths_for(path)
|
|
306
400
|
if has_fresh_test(all_candidates, _freshness_seconds()):
|
|
307
401
|
emit_allow()
|