claude-dev-env 1.35.0 → 1.36.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/agents/clean-coder.md +109 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +451 -39
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +182 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
- package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
- package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
- package/hooks/blocking/windows_rmtree_blocker.py +23 -6
- package/hooks/config/banned_identifiers_constants.py +24 -0
- package/hooks/config/hardcoded_user_path_constants.py +12 -0
- package/hooks/config/hook_log_extractor_constants.py +1 -1
- package/hooks/config/pre_tool_use_stdin.py +48 -0
- package/hooks/config/setup_project_paths_constants.py +4 -0
- package/hooks/config/stuttering_check_config.py +14 -0
- package/hooks/config/stuttering_import_binding_constants.py +11 -0
- package/hooks/config/sys_path_insert_constants.py +4 -0
- package/hooks/config/test_banned_identifiers_constants.py +48 -0
- package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
- package/hooks/config/test_hook_log_extractor_constants.py +3 -3
- package/hooks/config/test_pre_tool_use_stdin.py +80 -0
- package/hooks/config/unused_module_import_constants.py +7 -0
- package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
- package/hooks/git-hooks/config.py +3 -3
- package/hooks/git-hooks/test_gate_utils.py +10 -10
- package/hooks/mypy.ini +2 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +125 -0
- package/skills/bugteam/CONSTRAINTS.md +12 -6
- package/skills/bugteam/SKILL.md +77 -91
- package/skills/bugteam/SKILL_EVALS.md +25 -23
- package/skills/bugteam/reference/README.md +2 -0
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
- package/skills/bugteam/test_skill_additions.py +13 -4
- package/skills/bugteam/test_team_lifecycle.py +94 -0
- package/skills/findbugs/SKILL.md +3 -3
- package/skills/fixbugs/SKILL.md +4 -4
- package/skills/monitor-open-prs/SKILL.md +32 -2
- package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
- package/skills/pr-converge/SKILL.md +562 -97
- package/skills/pr-converge/scripts/README.md +145 -0
- package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
- package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
- package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
- package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
- package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
- package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
- package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
- package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
- package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
- package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
- package/skills/pr-converge/scripts/view_pr_context.py +47 -0
- package/skills/pr-converge/test_team_lifecycle.py +47 -0
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
- package/skills/qbug/SKILL.md +4 -4
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
- package/skills/resume-review/SKILL.md +261 -0
- package/skills/bugteam/scripts/README.md +0 -58
- package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
- package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
- package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
- package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
- /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
|
@@ -33,10 +33,39 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Optional
|
|
34
34
|
|
|
35
35
|
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
36
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
36
37
|
if _BLOCKING_DIR not in sys.path:
|
|
37
38
|
sys.path.insert(0, _BLOCKING_DIR)
|
|
39
|
+
if _HOOKS_DIR not in sys.path:
|
|
40
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
38
41
|
|
|
39
42
|
from code_rules_path_utils import is_config_file # noqa: E402
|
|
43
|
+
from config.banned_identifiers_constants import ( # noqa: E402
|
|
44
|
+
ALL_BANNED_IDENTIFIERS,
|
|
45
|
+
BANNED_IDENTIFIER_MESSAGE_SUFFIX,
|
|
46
|
+
BANNED_IDENTIFIER_SKIP_ADVISORY,
|
|
47
|
+
MAX_BANNED_IDENTIFIER_ISSUES,
|
|
48
|
+
)
|
|
49
|
+
from config.hardcoded_user_path_constants import ( # noqa: E402
|
|
50
|
+
HARDCODED_USER_PATH_GUIDANCE,
|
|
51
|
+
HARDCODED_USER_PATH_PATTERN,
|
|
52
|
+
MAX_HARDCODED_USER_PATH_ISSUES,
|
|
53
|
+
)
|
|
54
|
+
from config.stuttering_check_config import ( # noqa: E402
|
|
55
|
+
MAX_STUTTERING_PREFIX_ISSUES,
|
|
56
|
+
STUTTERING_ALL_PREFIX_PATTERN,
|
|
57
|
+
)
|
|
58
|
+
from config.sys_path_insert_constants import MAX_SYS_PATH_INSERT_ISSUES, SYS_PATH_INSERT_GUIDANCE # noqa: E402
|
|
59
|
+
from config.unused_module_import_constants import ( # noqa: E402
|
|
60
|
+
MAX_UNUSED_IMPORT_ISSUES,
|
|
61
|
+
TYPE_CHECKING_IDENTIFIER,
|
|
62
|
+
UNUSED_IMPORT_GUIDANCE,
|
|
63
|
+
)
|
|
64
|
+
from config.stuttering_import_binding_constants import ( # noqa: E402
|
|
65
|
+
AST_LINENO_ATTRIBUTE,
|
|
66
|
+
MODULE_PATH_SEPARATOR,
|
|
67
|
+
WILDCARD_IMPORT_SENTINEL,
|
|
68
|
+
)
|
|
40
69
|
|
|
41
70
|
PYTHON_EXTENSIONS = {".py"}
|
|
42
71
|
JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
@@ -855,39 +884,56 @@ def check_constants_outside_config_advisory(content: str, file_path: str) -> lis
|
|
|
855
884
|
return _scan_function_body_constants(content)
|
|
856
885
|
|
|
857
886
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
BANNED_IDENTIFIER_MESSAGE_SUFFIX: str = "use descriptive name (see CODE_RULES Naming section)"
|
|
861
|
-
BANNED_IDENTIFIER_SKIP_ADVISORY: str = (
|
|
862
|
-
"banned-identifier check skipped: file did not parse as Python"
|
|
863
|
-
)
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
def _collect_banned_names_from_target(target: ast.expr) -> list[ast.Name]:
|
|
867
|
-
"""Return every banned ast.Name reachable through tuple/list unpacking or starred targets."""
|
|
887
|
+
def _collect_target_names(target: ast.expr) -> list[ast.Name]:
|
|
888
|
+
"""Return every ast.Name reachable through tuple/list/starred unpacking targets."""
|
|
868
889
|
if isinstance(target, ast.Name):
|
|
869
|
-
|
|
870
|
-
return [target]
|
|
871
|
-
return []
|
|
890
|
+
return [target]
|
|
872
891
|
if isinstance(target, (ast.Tuple, ast.List)):
|
|
873
|
-
|
|
892
|
+
names: list[ast.Name] = []
|
|
874
893
|
for each_element in target.elts:
|
|
875
|
-
|
|
876
|
-
return
|
|
894
|
+
names.extend(_collect_target_names(each_element))
|
|
895
|
+
return names
|
|
877
896
|
if isinstance(target, ast.Starred):
|
|
878
|
-
return
|
|
897
|
+
return _collect_target_names(target.value)
|
|
879
898
|
return []
|
|
880
899
|
|
|
881
900
|
|
|
901
|
+
def _collect_banned_names_from_target(target: ast.expr) -> list[ast.Name]:
|
|
902
|
+
"""Return every banned ast.Name reachable through tuple/list unpacking or starred targets."""
|
|
903
|
+
return [
|
|
904
|
+
each_name_node
|
|
905
|
+
for each_name_node in _collect_target_names(target)
|
|
906
|
+
if each_name_node.id in ALL_BANNED_IDENTIFIERS
|
|
907
|
+
]
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _value_is_parse_args_namespace_call(value_node: ast.AST | None) -> bool:
|
|
911
|
+
if value_node is None:
|
|
912
|
+
return False
|
|
913
|
+
if not isinstance(value_node, ast.Call):
|
|
914
|
+
return False
|
|
915
|
+
callee = value_node.func
|
|
916
|
+
return isinstance(callee, ast.Attribute) and callee.attr == "parse_args"
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _without_parse_args_namespace_exemption(
|
|
920
|
+
all_banned_names: list[ast.Name], value_node: ast.AST | None
|
|
921
|
+
) -> list[ast.Name]:
|
|
922
|
+
if not _value_is_parse_args_namespace_call(value_node):
|
|
923
|
+
return all_banned_names
|
|
924
|
+
return [each_name for each_name in all_banned_names if each_name.id != "args"]
|
|
925
|
+
|
|
926
|
+
|
|
882
927
|
def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
883
928
|
"""Return banned ast.Name nodes introduced by a single binding construct."""
|
|
884
929
|
if isinstance(node, ast.Assign):
|
|
885
930
|
banned_names: list[ast.Name] = []
|
|
886
931
|
for each_target in node.targets:
|
|
887
932
|
banned_names.extend(_collect_banned_names_from_target(each_target))
|
|
888
|
-
return banned_names
|
|
933
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
889
934
|
if isinstance(node, ast.AnnAssign):
|
|
890
|
-
|
|
935
|
+
banned_names = _collect_banned_names_from_target(node.target)
|
|
936
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
891
937
|
if isinstance(node, (ast.For, ast.AsyncFor)):
|
|
892
938
|
return _collect_banned_names_from_target(node.target)
|
|
893
939
|
if isinstance(node, ast.comprehension):
|
|
@@ -897,7 +943,8 @@ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
|
897
943
|
return []
|
|
898
944
|
return _collect_banned_names_from_target(node.optional_vars)
|
|
899
945
|
if isinstance(node, ast.NamedExpr):
|
|
900
|
-
|
|
946
|
+
banned_names = _collect_banned_names_from_target(node.target)
|
|
947
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
901
948
|
return []
|
|
902
949
|
|
|
903
950
|
|
|
@@ -1981,10 +2028,10 @@ def check_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
|
1981
2028
|
issues.append(
|
|
1982
2029
|
f"Line {target_line}: Collection constant {target_name} - prefix with ALL_ (CODE_RULES §5)"
|
|
1983
2030
|
)
|
|
1984
|
-
for
|
|
1985
|
-
if not isinstance(
|
|
2031
|
+
for each_walked_node in ast.walk(tree):
|
|
2032
|
+
if not isinstance(each_walked_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1986
2033
|
continue
|
|
1987
|
-
for each_arg in _collect_annotated_arguments(
|
|
2034
|
+
for each_arg in _collect_annotated_arguments(each_walked_node):
|
|
1988
2035
|
if not _annotation_names_collection(each_arg.annotation):
|
|
1989
2036
|
continue
|
|
1990
2037
|
if each_arg.arg in {"self", "cls"}:
|
|
@@ -1997,6 +2044,369 @@ def check_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
|
1997
2044
|
return issues
|
|
1998
2045
|
|
|
1999
2046
|
|
|
2047
|
+
def _is_stuttering_all_name(name: str) -> bool:
|
|
2048
|
+
return bool(STUTTERING_ALL_PREFIX_PATTERN.match(name))
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
def _walk_assignment_targets(target: ast.expr) -> list[ast.Name]:
|
|
2052
|
+
"""Recursively collect ast.Name targets through tuple/list/starred unpacking."""
|
|
2053
|
+
if isinstance(target, ast.Name):
|
|
2054
|
+
return [target]
|
|
2055
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
2056
|
+
names: list[ast.Name] = []
|
|
2057
|
+
for each_element in target.elts:
|
|
2058
|
+
names.extend(_walk_assignment_targets(each_element))
|
|
2059
|
+
return names
|
|
2060
|
+
if isinstance(target, ast.Starred):
|
|
2061
|
+
return _walk_assignment_targets(target.value)
|
|
2062
|
+
return []
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
def _collect_stuttering_name_bindings(tree: ast.Module) -> list[tuple[str, int]]:
|
|
2066
|
+
"""Return (name, line_number) for bindings whose introduced name stutters all_/ALL_ prefixes.
|
|
2067
|
+
|
|
2068
|
+
Covers assignments, loops, parameters, walrus targets, comprehensions, with/except
|
|
2069
|
+
aliases, import aliases, and class definitions.
|
|
2070
|
+
"""
|
|
2071
|
+
bindings: list[tuple[str, int]] = []
|
|
2072
|
+
for each_node in ast.walk(tree):
|
|
2073
|
+
if isinstance(each_node, ast.Assign):
|
|
2074
|
+
for each_target in each_node.targets:
|
|
2075
|
+
for each_name in _walk_assignment_targets(each_target):
|
|
2076
|
+
if _is_stuttering_all_name(each_name.id):
|
|
2077
|
+
bindings.append((each_name.id, each_name.lineno))
|
|
2078
|
+
elif isinstance(each_node, ast.AnnAssign) and isinstance(each_node.target, ast.Name):
|
|
2079
|
+
if _is_stuttering_all_name(each_node.target.id):
|
|
2080
|
+
bindings.append((each_node.target.id, each_node.target.lineno))
|
|
2081
|
+
elif isinstance(each_node, (ast.For, ast.AsyncFor)):
|
|
2082
|
+
for each_name in _walk_assignment_targets(each_node.target):
|
|
2083
|
+
if _is_stuttering_all_name(each_name.id):
|
|
2084
|
+
bindings.append((each_name.id, each_name.lineno))
|
|
2085
|
+
elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2086
|
+
if _is_stuttering_all_name(each_node.name):
|
|
2087
|
+
bindings.append((each_node.name, each_node.lineno))
|
|
2088
|
+
for each_arg in _collect_annotated_arguments(each_node):
|
|
2089
|
+
if _is_stuttering_all_name(each_arg.arg):
|
|
2090
|
+
bindings.append((each_arg.arg, each_arg.lineno))
|
|
2091
|
+
elif isinstance(each_node, ast.NamedExpr) and isinstance(each_node.target, ast.Name):
|
|
2092
|
+
if _is_stuttering_all_name(each_node.target.id):
|
|
2093
|
+
bindings.append((each_node.target.id, each_node.target.lineno))
|
|
2094
|
+
elif isinstance(each_node, ast.comprehension):
|
|
2095
|
+
for each_name in _walk_assignment_targets(each_node.target):
|
|
2096
|
+
if _is_stuttering_all_name(each_name.id):
|
|
2097
|
+
bindings.append((each_name.id, each_name.lineno))
|
|
2098
|
+
elif isinstance(each_node, (ast.With, ast.AsyncWith)):
|
|
2099
|
+
for each_with_item in each_node.items:
|
|
2100
|
+
if each_with_item.optional_vars is None:
|
|
2101
|
+
continue
|
|
2102
|
+
for each_name in _walk_assignment_targets(each_with_item.optional_vars):
|
|
2103
|
+
if _is_stuttering_all_name(each_name.id):
|
|
2104
|
+
bindings.append((each_name.id, each_name.lineno))
|
|
2105
|
+
elif isinstance(each_node, ast.ExceptHandler):
|
|
2106
|
+
if each_node.name is not None and _is_stuttering_all_name(each_node.name):
|
|
2107
|
+
bindings.append((each_node.name, each_node.lineno))
|
|
2108
|
+
elif isinstance(each_node, ast.Import):
|
|
2109
|
+
for each_alias in each_node.names:
|
|
2110
|
+
bound_name = (
|
|
2111
|
+
each_alias.asname
|
|
2112
|
+
if each_alias.asname is not None
|
|
2113
|
+
else each_alias.name.split(MODULE_PATH_SEPARATOR, 1)[0]
|
|
2114
|
+
)
|
|
2115
|
+
if _is_stuttering_all_name(bound_name):
|
|
2116
|
+
line_number = getattr(each_alias, AST_LINENO_ATTRIBUTE, None) or each_node.lineno
|
|
2117
|
+
bindings.append((bound_name, line_number))
|
|
2118
|
+
elif isinstance(each_node, ast.ImportFrom):
|
|
2119
|
+
for each_alias in each_node.names:
|
|
2120
|
+
if each_alias.name == WILDCARD_IMPORT_SENTINEL:
|
|
2121
|
+
continue
|
|
2122
|
+
bound_name = (
|
|
2123
|
+
each_alias.asname
|
|
2124
|
+
if each_alias.asname is not None
|
|
2125
|
+
else each_alias.name
|
|
2126
|
+
)
|
|
2127
|
+
if _is_stuttering_all_name(bound_name):
|
|
2128
|
+
line_number = getattr(each_alias, AST_LINENO_ATTRIBUTE, None) or each_node.lineno
|
|
2129
|
+
bindings.append((bound_name, line_number))
|
|
2130
|
+
elif isinstance(each_node, ast.ClassDef):
|
|
2131
|
+
if _is_stuttering_all_name(each_node.name):
|
|
2132
|
+
bindings.append((each_node.name, each_node.lineno))
|
|
2133
|
+
return bindings
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def check_stuttering_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
2137
|
+
"""Flag identifiers stuttering the all_/ALL_ collection prefix (e.g., all_all_users)."""
|
|
2138
|
+
if is_test_file(file_path):
|
|
2139
|
+
return []
|
|
2140
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2141
|
+
return []
|
|
2142
|
+
try:
|
|
2143
|
+
tree = ast.parse(content)
|
|
2144
|
+
except SyntaxError:
|
|
2145
|
+
return []
|
|
2146
|
+
issues: list[str] = []
|
|
2147
|
+
for each_name, each_line_number in _collect_stuttering_name_bindings(tree):
|
|
2148
|
+
issues.append(
|
|
2149
|
+
f"Line {each_line_number}: Stuttering collection prefix {each_name!r}"
|
|
2150
|
+
f" - use a single all_/ALL_ prefix (CODE_RULES §5)"
|
|
2151
|
+
)
|
|
2152
|
+
if len(issues) >= MAX_STUTTERING_PREFIX_ISSUES:
|
|
2153
|
+
break
|
|
2154
|
+
return issues
|
|
2155
|
+
|
|
2156
|
+
|
|
2157
|
+
def check_hardcoded_user_paths(content: str, file_path: str) -> list[str]:
|
|
2158
|
+
"""Flag string literals naming a specific user's home directory.
|
|
2159
|
+
|
|
2160
|
+
Catches non-portable paths like `C:/Users/jon/...`, `/Users/alice/...`,
|
|
2161
|
+
and `/home/bob/...` that surface in production code (PR #257 evidence).
|
|
2162
|
+
Test files, config/ files, workflow registry files, migration files,
|
|
2163
|
+
and hook infrastructure files are exempt. Hook infrastructure exemption
|
|
2164
|
+
matches the pattern used by check_library_print and other check
|
|
2165
|
+
functions, and prevents the enforcer from self-blocking on its own
|
|
2166
|
+
HARDCODED_USER_PATH_PATTERN definition.
|
|
2167
|
+
"""
|
|
2168
|
+
if is_test_file(file_path):
|
|
2169
|
+
return []
|
|
2170
|
+
if is_config_file(file_path):
|
|
2171
|
+
return []
|
|
2172
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2173
|
+
return []
|
|
2174
|
+
if is_hook_infrastructure(file_path):
|
|
2175
|
+
return []
|
|
2176
|
+
try:
|
|
2177
|
+
tree = ast.parse(content)
|
|
2178
|
+
except SyntaxError:
|
|
2179
|
+
return []
|
|
2180
|
+
docstring_node_ids = _collect_docstring_node_ids(tree)
|
|
2181
|
+
issues: list[str] = []
|
|
2182
|
+
for each_node in ast.walk(tree):
|
|
2183
|
+
if not isinstance(each_node, ast.Constant):
|
|
2184
|
+
continue
|
|
2185
|
+
if not isinstance(each_node.value, str):
|
|
2186
|
+
continue
|
|
2187
|
+
if id(each_node) in docstring_node_ids:
|
|
2188
|
+
continue
|
|
2189
|
+
match = HARDCODED_USER_PATH_PATTERN.search(each_node.value)
|
|
2190
|
+
if match is None:
|
|
2191
|
+
continue
|
|
2192
|
+
issues.append(
|
|
2193
|
+
f"Line {each_node.lineno}: hardcoded user path {match.group(0)!r}"
|
|
2194
|
+
f" — {HARDCODED_USER_PATH_GUIDANCE}"
|
|
2195
|
+
)
|
|
2196
|
+
if len(issues) >= MAX_HARDCODED_USER_PATH_ISSUES:
|
|
2197
|
+
break
|
|
2198
|
+
return issues
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def _is_sys_path_insert_call(call_node: ast.Call) -> bool:
|
|
2202
|
+
function_reference = call_node.func
|
|
2203
|
+
if not isinstance(function_reference, ast.Attribute) or function_reference.attr != "insert":
|
|
2204
|
+
return False
|
|
2205
|
+
receiver = function_reference.value
|
|
2206
|
+
if not isinstance(receiver, ast.Attribute) or receiver.attr != "path":
|
|
2207
|
+
return False
|
|
2208
|
+
receiver_value = receiver.value
|
|
2209
|
+
return isinstance(receiver_value, ast.Name) and receiver_value.id == "sys"
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def _is_sys_path_membership_if_test(if_test_expression: ast.AST) -> bool:
|
|
2213
|
+
"""Return True when `if X not in sys.path:` would guard a then-branch insert.
|
|
2214
|
+
|
|
2215
|
+
Only `ast.NotIn` is accepted: `_scope_has_guard_for_insert` walks the
|
|
2216
|
+
then-branch (`each_statement.body`) for the insert, so accepting `ast.In`
|
|
2217
|
+
here would silently approve `if X in sys.path: sys.path.insert(0, X)` —
|
|
2218
|
+
code that always inserts a duplicate. The else-branch is intentionally not
|
|
2219
|
+
inspected; a guard that places the insert in the else-branch of `if X in
|
|
2220
|
+
sys.path:` is unconventional and not supported.
|
|
2221
|
+
"""
|
|
2222
|
+
if not isinstance(if_test_expression, ast.Compare):
|
|
2223
|
+
return False
|
|
2224
|
+
if len(if_test_expression.ops) != 1:
|
|
2225
|
+
return False
|
|
2226
|
+
if not isinstance(if_test_expression.ops[0], ast.NotIn):
|
|
2227
|
+
return False
|
|
2228
|
+
membership_target = if_test_expression.comparators[0]
|
|
2229
|
+
if not isinstance(membership_target, ast.Attribute) or membership_target.attr != "path":
|
|
2230
|
+
return False
|
|
2231
|
+
membership_receiver = membership_target.value
|
|
2232
|
+
return isinstance(membership_receiver, ast.Name) and membership_receiver.id == "sys"
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
def _scope_has_guard_for_insert(
|
|
2236
|
+
all_scope_statements: list[ast.stmt],
|
|
2237
|
+
insert_call_node: ast.Call,
|
|
2238
|
+
) -> bool:
|
|
2239
|
+
for each_statement in all_scope_statements:
|
|
2240
|
+
if not isinstance(each_statement, ast.If):
|
|
2241
|
+
continue
|
|
2242
|
+
membership_test = each_statement.test
|
|
2243
|
+
if not isinstance(membership_test, ast.Compare):
|
|
2244
|
+
continue
|
|
2245
|
+
if not _is_sys_path_membership_if_test(membership_test):
|
|
2246
|
+
continue
|
|
2247
|
+
for each_inner in each_statement.body:
|
|
2248
|
+
if isinstance(each_inner, ast.Expr) and each_inner.value is insert_call_node:
|
|
2249
|
+
if len(insert_call_node.args) < 2:
|
|
2250
|
+
return True
|
|
2251
|
+
if ast.dump(membership_test.left) == ast.dump(insert_call_node.args[1]):
|
|
2252
|
+
return True
|
|
2253
|
+
return False
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
def _enclosing_scope_body(
|
|
2257
|
+
insert_call_node: ast.Call,
|
|
2258
|
+
parent_by_node_id: dict[int, ast.AST],
|
|
2259
|
+
) -> list[ast.stmt]:
|
|
2260
|
+
parent = parent_by_node_id.get(id(insert_call_node))
|
|
2261
|
+
while parent is not None:
|
|
2262
|
+
if isinstance(parent, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
2263
|
+
return list(parent.body)
|
|
2264
|
+
parent = parent_by_node_id.get(id(parent))
|
|
2265
|
+
return []
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
def check_sys_path_insert_deduplication_guard(content: str, file_path: str) -> list[str]:
|
|
2269
|
+
"""Flag sys.path.insert calls that lack a `not in sys.path` guard.
|
|
2270
|
+
|
|
2271
|
+
Repeated module reloads can push the same entry onto sys.path multiple
|
|
2272
|
+
times when the call is unguarded. The repo convention is to wrap the
|
|
2273
|
+
call with `if <path> not in sys.path:`. PR #289 surfaced two scripts
|
|
2274
|
+
(grant_project_claude_permissions.py, revoke_project_claude_permissions.py)
|
|
2275
|
+
that bypassed the convention.
|
|
2276
|
+
"""
|
|
2277
|
+
if is_test_file(file_path):
|
|
2278
|
+
return []
|
|
2279
|
+
if is_hook_infrastructure(file_path):
|
|
2280
|
+
return []
|
|
2281
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2282
|
+
return []
|
|
2283
|
+
try:
|
|
2284
|
+
tree = ast.parse(content)
|
|
2285
|
+
except SyntaxError:
|
|
2286
|
+
return []
|
|
2287
|
+
parent_by_node_id = _build_parent_map(tree)
|
|
2288
|
+
issues: list[str] = []
|
|
2289
|
+
for each_node in ast.walk(tree):
|
|
2290
|
+
if not isinstance(each_node, ast.Call):
|
|
2291
|
+
continue
|
|
2292
|
+
if not _is_sys_path_insert_call(each_node):
|
|
2293
|
+
continue
|
|
2294
|
+
all_scope_statements = _enclosing_scope_body(each_node, parent_by_node_id)
|
|
2295
|
+
if _scope_has_guard_for_insert(all_scope_statements, each_node):
|
|
2296
|
+
continue
|
|
2297
|
+
issues.append(
|
|
2298
|
+
f"Line {each_node.lineno}: unguarded sys.path.insert"
|
|
2299
|
+
f" — {SYS_PATH_INSERT_GUIDANCE}"
|
|
2300
|
+
)
|
|
2301
|
+
if len(issues) >= MAX_SYS_PATH_INSERT_ISSUES:
|
|
2302
|
+
break
|
|
2303
|
+
return issues
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def _import_alias_pairs(
|
|
2307
|
+
import_node: ast.Import | ast.ImportFrom,
|
|
2308
|
+
) -> list[tuple[str, int, int | None]]:
|
|
2309
|
+
"""Return (binding_name, alias_line, from_keyword_line) for each name introduced.
|
|
2310
|
+
|
|
2311
|
+
The from-keyword line is None for plain `import X` statements; for
|
|
2312
|
+
`from X import (...)` it carries the line of the `from` keyword so
|
|
2313
|
+
callers can honor a `# noqa` placed on the opening line of a
|
|
2314
|
+
multi-line import block.
|
|
2315
|
+
"""
|
|
2316
|
+
bindings: list[tuple[str, int, int | None]] = []
|
|
2317
|
+
from_keyword_line = import_node.lineno if isinstance(import_node, ast.ImportFrom) else None
|
|
2318
|
+
for each_alias in import_node.names:
|
|
2319
|
+
if each_alias.name == "*":
|
|
2320
|
+
continue
|
|
2321
|
+
binding_name = each_alias.asname if each_alias.asname else each_alias.name.split(".")[0]
|
|
2322
|
+
alias_line = each_alias.lineno or import_node.lineno
|
|
2323
|
+
bindings.append((binding_name, alias_line, from_keyword_line))
|
|
2324
|
+
return bindings
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
def _name_appears_outside_imports(
|
|
2328
|
+
all_content_lines: list[str],
|
|
2329
|
+
all_import_line_numbers: set[int],
|
|
2330
|
+
name: str,
|
|
2331
|
+
) -> bool:
|
|
2332
|
+
name_pattern = re.compile(rf"\b{re.escape(name)}\b")
|
|
2333
|
+
for each_line_index, each_line in enumerate(all_content_lines, start=1):
|
|
2334
|
+
if each_line_index in all_import_line_numbers:
|
|
2335
|
+
continue
|
|
2336
|
+
if name_pattern.search(each_line):
|
|
2337
|
+
return True
|
|
2338
|
+
return False
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
def _line_carries_noqa_marker(line_text: str) -> bool:
|
|
2342
|
+
return "# noqa" in line_text or "#noqa" in line_text
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
|
+
def check_unused_module_level_imports(content: str, file_path: str) -> list[str]:
|
|
2346
|
+
"""Flag module-level imports that are never referenced in the rest of the file.
|
|
2347
|
+
|
|
2348
|
+
The rule is intentionally conservative — files declaring __all__ or
|
|
2349
|
+
using TYPE_CHECKING are skipped to avoid false positives on
|
|
2350
|
+
re-exports and annotation-only imports.
|
|
2351
|
+
"""
|
|
2352
|
+
if is_test_file(file_path):
|
|
2353
|
+
return []
|
|
2354
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2355
|
+
return []
|
|
2356
|
+
try:
|
|
2357
|
+
tree = ast.parse(content)
|
|
2358
|
+
except SyntaxError:
|
|
2359
|
+
return []
|
|
2360
|
+
file_declares_dunder_all = any(
|
|
2361
|
+
(
|
|
2362
|
+
isinstance(each_node, ast.Assign)
|
|
2363
|
+
and any(
|
|
2364
|
+
isinstance(each_target, ast.Name) and each_target.id == "__all__"
|
|
2365
|
+
for each_target in each_node.targets
|
|
2366
|
+
)
|
|
2367
|
+
)
|
|
2368
|
+
or (
|
|
2369
|
+
isinstance(each_node, ast.AnnAssign)
|
|
2370
|
+
and isinstance(each_node.target, ast.Name)
|
|
2371
|
+
and each_node.target.id == "__all__"
|
|
2372
|
+
)
|
|
2373
|
+
for each_node in tree.body
|
|
2374
|
+
)
|
|
2375
|
+
if file_declares_dunder_all:
|
|
2376
|
+
return []
|
|
2377
|
+
if TYPE_CHECKING_IDENTIFIER in content:
|
|
2378
|
+
return []
|
|
2379
|
+
content_lines = content.splitlines()
|
|
2380
|
+
import_line_numbers: set[int] = set()
|
|
2381
|
+
import_bindings: list[tuple[str, int, int | None]] = []
|
|
2382
|
+
for each_node in tree.body:
|
|
2383
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
2384
|
+
import_line_numbers.add(each_node.lineno)
|
|
2385
|
+
for each_alias in each_node.names:
|
|
2386
|
+
import_line_numbers.add(each_alias.lineno or each_node.lineno)
|
|
2387
|
+
if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
|
|
2388
|
+
continue
|
|
2389
|
+
for each_binding in _import_alias_pairs(each_node):
|
|
2390
|
+
import_bindings.append(each_binding)
|
|
2391
|
+
issues: list[str] = []
|
|
2392
|
+
for each_name, each_line_number, each_from_keyword_line in import_bindings:
|
|
2393
|
+
if 1 <= each_line_number <= len(content_lines):
|
|
2394
|
+
if _line_carries_noqa_marker(content_lines[each_line_number - 1]):
|
|
2395
|
+
continue
|
|
2396
|
+
if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(content_lines):
|
|
2397
|
+
if _line_carries_noqa_marker(content_lines[each_from_keyword_line - 1]):
|
|
2398
|
+
continue
|
|
2399
|
+
if _name_appears_outside_imports(content_lines, import_line_numbers, each_name):
|
|
2400
|
+
continue
|
|
2401
|
+
issues.append(
|
|
2402
|
+
f"Line {each_line_number}: unused module-level import {each_name!r}"
|
|
2403
|
+
f" — {UNUSED_IMPORT_GUIDANCE}"
|
|
2404
|
+
)
|
|
2405
|
+
if len(issues) >= MAX_UNUSED_IMPORT_ISSUES:
|
|
2406
|
+
break
|
|
2407
|
+
return issues
|
|
2408
|
+
|
|
2409
|
+
|
|
2000
2410
|
def _is_cli_entry_point(file_path: str) -> bool:
|
|
2001
2411
|
path_lower = file_path.lower().replace("\\", "/")
|
|
2002
2412
|
return any(marker.replace("\\", "/") in path_lower for marker in CLI_FILE_PATH_MARKERS)
|
|
@@ -2187,23 +2597,21 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
|
|
|
2187
2597
|
for node in ast.walk(tree):
|
|
2188
2598
|
if not isinstance(node, (ast.For, ast.AsyncFor)):
|
|
2189
2599
|
continue
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2600
|
+
for each_name_node in _collect_target_names(node.target):
|
|
2601
|
+
target_name = each_name_node.id
|
|
2602
|
+
if target_name in LOOP_INDEX_LETTER_EXEMPTIONS:
|
|
2603
|
+
continue
|
|
2604
|
+
if target_name == BARE_EACH_TOKEN:
|
|
2605
|
+
issues.append(
|
|
2606
|
+
f"Line {each_name_node.lineno}: loop variable 'each' is a bare token without subject"
|
|
2607
|
+
f" - rename to each_<subject> (CODE_RULES §5)"
|
|
2608
|
+
)
|
|
2609
|
+
continue
|
|
2610
|
+
if target_name.startswith(EACH_PREFIX) and len(target_name) > len(EACH_PREFIX):
|
|
2611
|
+
continue
|
|
2197
2612
|
issues.append(
|
|
2198
|
-
f"Line {
|
|
2199
|
-
f" - rename to each_<subject> (CODE_RULES §5)"
|
|
2613
|
+
f"Line {each_name_node.lineno}: loop variable {target_name!r} - prefix with each_ (CODE_RULES §5)"
|
|
2200
2614
|
)
|
|
2201
|
-
continue
|
|
2202
|
-
if target_name.startswith(EACH_PREFIX) and len(target_name) > len(EACH_PREFIX):
|
|
2203
|
-
continue
|
|
2204
|
-
issues.append(
|
|
2205
|
-
f"Line {target.lineno}: loop variable {target_name!r} - prefix with each_ (CODE_RULES §5)"
|
|
2206
|
-
)
|
|
2207
2615
|
return issues
|
|
2208
2616
|
|
|
2209
2617
|
|
|
@@ -2281,6 +2689,10 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
2281
2689
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
2282
2690
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
2283
2691
|
all_issues.extend(check_collection_prefix(content, file_path))
|
|
2692
|
+
all_issues.extend(check_stuttering_collection_prefix(content, file_path))
|
|
2693
|
+
all_issues.extend(check_hardcoded_user_paths(content, file_path))
|
|
2694
|
+
all_issues.extend(check_sys_path_insert_deduplication_guard(content, file_path))
|
|
2695
|
+
all_issues.extend(check_unused_module_level_imports(content, file_path))
|
|
2284
2696
|
all_issues.extend(check_library_print(content, file_path))
|
|
2285
2697
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
2286
2698
|
all_issues.extend(check_return_annotations(content, file_path))
|
|
@@ -2357,4 +2769,4 @@ def main() -> None:
|
|
|
2357
2769
|
|
|
2358
2770
|
|
|
2359
2771
|
if __name__ == "__main__":
|
|
2360
|
-
main()
|
|
2772
|
+
main()
|
|
@@ -26,6 +26,7 @@ def _insert_hooks_tree_for_imports() -> None:
|
|
|
26
26
|
_insert_hooks_tree_for_imports()
|
|
27
27
|
|
|
28
28
|
from config.dynamic_stderr_handler import DynamicStderrHandler
|
|
29
|
+
from config.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
29
30
|
from config.path_rewriter_constants import (
|
|
30
31
|
BASH_TOOL_NAME,
|
|
31
32
|
HOOK_EVENT_NAME,
|
|
@@ -135,12 +136,17 @@ def _build_allow_response(rewritten_command: str, original_tool_input: dict) ->
|
|
|
135
136
|
|
|
136
137
|
def main() -> None:
|
|
137
138
|
try:
|
|
138
|
-
hook_input =
|
|
139
|
-
|
|
139
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
140
|
+
if hook_input is None:
|
|
141
|
+
sys.exit(0)
|
|
142
|
+
raw_tool_name = hook_input.get("tool_name", "")
|
|
143
|
+
raw_tool_input = hook_input.get("tool_input", {})
|
|
144
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
145
|
+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
140
146
|
if tool_name != BASH_TOOL_NAME:
|
|
141
147
|
sys.exit(0)
|
|
142
|
-
|
|
143
|
-
command =
|
|
148
|
+
raw_command = tool_input.get("command", "")
|
|
149
|
+
command = raw_command if isinstance(raw_command, str) else ""
|
|
144
150
|
if not command_invokes_es_exe(command):
|
|
145
151
|
sys.exit(0)
|
|
146
152
|
known_registry = load_registry()
|