claude-dev-env 1.34.1 → 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/agents/docs-agent.md +1 -1
- package/agents/project-docs-analyzer.md +0 -1
- package/agents/skill-to-agent-converter.md +0 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/commands/initialize.md +0 -1
- package/commands/readability-review.md +4 -4
- package/commands/review-plan.md +2 -4
- package/commands/stubcheck.md +1 -2
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +686 -60
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +273 -39
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +97 -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 +328 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +0 -20
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +33 -11
- package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +0 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_literal_collections.py +155 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +194 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -13
- package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +0 -26
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +234 -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/PROMPTS.md +0 -39
- package/skills/bugteam/SKILL.md +93 -125
- 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/copilot-gap-analysis.md +12 -0
- 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 +576 -95
- 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/agents/agent-writer.md +0 -157
- package/agents/config-centralizer.md +0 -686
- package/agents/config-extraction-agent.md +0 -225
- package/agents/doc-orchestrator.md +0 -47
- package/agents/docx-agent.md +0 -211
- package/agents/magic-value-eliminator-agent.md +0 -72
- package/agents/mandatory-agent-workflow-agent.md +0 -88
- package/agents/parallel-workflow-coordinator.md +0 -779
- package/agents/pdf-agent.md +0 -302
- package/agents/project-context-loader.md +0 -238
- package/agents/readability-review-agent.md +0 -76
- package/agents/refactoring-specialist.md +0 -69
- package/agents/right-sized-engineer.md +0 -129
- package/agents/session-continuity-manager.md +0 -53
- package/agents/stub-detector-agent.md +0 -140
- package/agents/tdd-test-writer.md +0 -62
- package/agents/test-data-builder.md +0 -68
- package/agents/tooling-builder.md +0 -78
- package/agents/validation-expert.md +0 -71
- package/agents/xlsx-agent.md +0 -169
- 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
|
@@ -28,14 +28,44 @@ import json
|
|
|
28
28
|
import re
|
|
29
29
|
import sys
|
|
30
30
|
import tokenize
|
|
31
|
+
from collections.abc import Iterator
|
|
31
32
|
from pathlib import Path
|
|
32
33
|
from typing import Optional
|
|
33
34
|
|
|
34
35
|
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
36
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
35
37
|
if _BLOCKING_DIR not in sys.path:
|
|
36
38
|
sys.path.insert(0, _BLOCKING_DIR)
|
|
39
|
+
if _HOOKS_DIR not in sys.path:
|
|
40
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
37
41
|
|
|
38
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
|
+
)
|
|
39
69
|
|
|
40
70
|
PYTHON_EXTENSIONS = {".py"}
|
|
41
71
|
JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
@@ -50,14 +80,12 @@ ADVISORY_LINE_THRESHOLD_SOFT = 400
|
|
|
50
80
|
ADVISORY_LINE_THRESHOLD_HARD = 1000
|
|
51
81
|
|
|
52
82
|
BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_")
|
|
53
|
-
BOOLEAN_NAMING_ISSUE_CAP = 3
|
|
54
83
|
UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
55
84
|
|
|
56
85
|
|
|
57
86
|
TYPE_CHECKING_BLOCK_PATTERN = re.compile(r"^(?P<indent>\s*)if\s+(typing\.)?TYPE_CHECKING\s*:\s*$")
|
|
58
87
|
IMPORT_STATEMENT_PREFIXES: tuple[str, ...] = ("import ", "from ")
|
|
59
88
|
NOT_INSIDE_TYPE_CHECKING_BLOCK = -1
|
|
60
|
-
MAX_ISSUES_PER_CHECK = 3
|
|
61
89
|
FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
|
|
62
90
|
|
|
63
91
|
COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
|
|
@@ -355,9 +383,6 @@ def check_imports_at_top(content: str) -> list[str]:
|
|
|
355
383
|
if stripped.startswith(IMPORT_STATEMENT_PREFIXES):
|
|
356
384
|
issues.append(f"Line {line_number}: Import inside function - move to top of file")
|
|
357
385
|
|
|
358
|
-
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
359
|
-
break
|
|
360
|
-
|
|
361
386
|
return issues
|
|
362
387
|
|
|
363
388
|
|
|
@@ -758,7 +783,7 @@ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
|
|
|
758
783
|
inside_function = False
|
|
759
784
|
inside_class = False
|
|
760
785
|
|
|
761
|
-
constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})
|
|
786
|
+
constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
|
|
762
787
|
|
|
763
788
|
for line_number, line in enumerate(lines, 1):
|
|
764
789
|
stripped = line.strip()
|
|
@@ -787,9 +812,6 @@ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
|
|
|
787
812
|
if constant_name not in ("__all__",):
|
|
788
813
|
issues.append(f"Line {line_number}: Constant {constant_name} - move to config/")
|
|
789
814
|
|
|
790
|
-
if len(issues) >= 3:
|
|
791
|
-
break
|
|
792
|
-
|
|
793
815
|
return issues
|
|
794
816
|
|
|
795
817
|
|
|
@@ -811,12 +833,11 @@ def _scan_function_body_constants(content: str) -> list[str]:
|
|
|
811
833
|
|
|
812
834
|
Only lines inside a function body (tracked via an indent stack) are
|
|
813
835
|
flagged. Module-level assignments and class-body assignments are ignored.
|
|
814
|
-
Returns at most MAX_ISSUES_PER_CHECK entries.
|
|
815
836
|
"""
|
|
816
837
|
advisory_issues: list[str] = []
|
|
817
838
|
lines = content.split("\n")
|
|
818
839
|
function_indent_stack: list[int] = []
|
|
819
|
-
constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})
|
|
840
|
+
constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
|
|
820
841
|
|
|
821
842
|
for line_number, line in enumerate(lines, 1):
|
|
822
843
|
stripped = line.strip()
|
|
@@ -846,9 +867,6 @@ def _scan_function_body_constants(content: str) -> list[str]:
|
|
|
846
867
|
f"Line {line_number}: Function-local constant {constant_name} - consider moving to config/"
|
|
847
868
|
)
|
|
848
869
|
|
|
849
|
-
if len(advisory_issues) >= MAX_ISSUES_PER_CHECK:
|
|
850
|
-
break
|
|
851
|
-
|
|
852
870
|
return advisory_issues
|
|
853
871
|
|
|
854
872
|
|
|
@@ -866,39 +884,56 @@ def check_constants_outside_config_advisory(content: str, file_path: str) -> lis
|
|
|
866
884
|
return _scan_function_body_constants(content)
|
|
867
885
|
|
|
868
886
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
BANNED_IDENTIFIER_MESSAGE_SUFFIX: str = "use descriptive name (see CODE_RULES Naming section)"
|
|
872
|
-
BANNED_IDENTIFIER_SKIP_ADVISORY: str = (
|
|
873
|
-
"banned-identifier check skipped: file did not parse as Python"
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
def _collect_banned_names_from_target(target: ast.expr) -> list[ast.Name]:
|
|
878
|
-
"""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."""
|
|
879
889
|
if isinstance(target, ast.Name):
|
|
880
|
-
|
|
881
|
-
return [target]
|
|
882
|
-
return []
|
|
890
|
+
return [target]
|
|
883
891
|
if isinstance(target, (ast.Tuple, ast.List)):
|
|
884
|
-
|
|
892
|
+
names: list[ast.Name] = []
|
|
885
893
|
for each_element in target.elts:
|
|
886
|
-
|
|
887
|
-
return
|
|
894
|
+
names.extend(_collect_target_names(each_element))
|
|
895
|
+
return names
|
|
888
896
|
if isinstance(target, ast.Starred):
|
|
889
|
-
return
|
|
897
|
+
return _collect_target_names(target.value)
|
|
890
898
|
return []
|
|
891
899
|
|
|
892
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
|
+
|
|
893
927
|
def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
894
928
|
"""Return banned ast.Name nodes introduced by a single binding construct."""
|
|
895
929
|
if isinstance(node, ast.Assign):
|
|
896
930
|
banned_names: list[ast.Name] = []
|
|
897
931
|
for each_target in node.targets:
|
|
898
932
|
banned_names.extend(_collect_banned_names_from_target(each_target))
|
|
899
|
-
return banned_names
|
|
933
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
900
934
|
if isinstance(node, ast.AnnAssign):
|
|
901
|
-
|
|
935
|
+
banned_names = _collect_banned_names_from_target(node.target)
|
|
936
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
902
937
|
if isinstance(node, (ast.For, ast.AsyncFor)):
|
|
903
938
|
return _collect_banned_names_from_target(node.target)
|
|
904
939
|
if isinstance(node, ast.comprehension):
|
|
@@ -908,7 +943,8 @@ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
|
908
943
|
return []
|
|
909
944
|
return _collect_banned_names_from_target(node.optional_vars)
|
|
910
945
|
if isinstance(node, ast.NamedExpr):
|
|
911
|
-
|
|
946
|
+
banned_names = _collect_banned_names_from_target(node.target)
|
|
947
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
912
948
|
return []
|
|
913
949
|
|
|
914
950
|
|
|
@@ -1066,8 +1102,6 @@ def check_boolean_naming(content: str, file_path: str) -> list[str]:
|
|
|
1066
1102
|
issues.append(
|
|
1067
1103
|
f"Line {line_number}: Boolean {name} - prefix with is_/has_/should_/can_"
|
|
1068
1104
|
)
|
|
1069
|
-
if len(issues) >= BOOLEAN_NAMING_ISSUE_CAP:
|
|
1070
|
-
break
|
|
1071
1105
|
return issues
|
|
1072
1106
|
|
|
1073
1107
|
|
|
@@ -1110,8 +1144,6 @@ def check_skip_decorators_in_tests(content: str, file_path: str) -> list[str]:
|
|
|
1110
1144
|
f"Line {each_decorator.lineno}: @skip decorator on test"
|
|
1111
1145
|
f" — tests must fail on missing deps"
|
|
1112
1146
|
)
|
|
1113
|
-
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1114
|
-
return issues
|
|
1115
1147
|
|
|
1116
1148
|
return issues
|
|
1117
1149
|
|
|
@@ -1215,8 +1247,6 @@ def check_existence_check_tests(content: str, file_path: str) -> list[str]:
|
|
|
1215
1247
|
f"Line {each_node.lineno}: existence-check test"
|
|
1216
1248
|
f" — delete or replace with a behavior test"
|
|
1217
1249
|
)
|
|
1218
|
-
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1219
|
-
return issues
|
|
1220
1250
|
|
|
1221
1251
|
return issues
|
|
1222
1252
|
|
|
@@ -1278,8 +1308,6 @@ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
|
|
|
1278
1308
|
f"Line {each_node.lineno}: constant-value test"
|
|
1279
1309
|
f" — delete; tests must cover behavior"
|
|
1280
1310
|
)
|
|
1281
|
-
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1282
|
-
return issues
|
|
1283
1311
|
|
|
1284
1312
|
return issues
|
|
1285
1313
|
|
|
@@ -1391,8 +1419,6 @@ def check_file_global_constants_use_count(content: str, file_path: str) -> list[
|
|
|
1391
1419
|
issues.append(
|
|
1392
1420
|
f"Line {line_number}: File-global constant {each_constant_name} used by only 1 function/method - move to method scope or add a second caller"
|
|
1393
1421
|
)
|
|
1394
|
-
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1395
|
-
break
|
|
1396
1422
|
|
|
1397
1423
|
return issues
|
|
1398
1424
|
|
|
@@ -1934,21 +1960,40 @@ def check_unused_optional_parameters(content: str, file_path: str) -> list[str]:
|
|
|
1934
1960
|
f"Line {function_node.lineno}: optional parameter {param_name}"
|
|
1935
1961
|
f" is never varied — inline default or drop"
|
|
1936
1962
|
)
|
|
1937
|
-
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1938
|
-
return issues
|
|
1939
1963
|
|
|
1940
1964
|
return issues
|
|
1941
1965
|
|
|
1942
1966
|
|
|
1967
|
+
UNION_TYPING_NAMES: frozenset[str] = frozenset({"Optional", "Union"})
|
|
1968
|
+
|
|
1969
|
+
|
|
1943
1970
|
def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
|
|
1944
1971
|
if annotation_node is None:
|
|
1945
1972
|
return False
|
|
1946
1973
|
if isinstance(annotation_node, ast.Name):
|
|
1947
1974
|
return annotation_node.id in COLLECTION_TYPE_NAMES
|
|
1948
|
-
if isinstance(annotation_node, ast.Subscript):
|
|
1949
|
-
return _annotation_names_collection(annotation_node.value)
|
|
1950
1975
|
if isinstance(annotation_node, ast.Attribute):
|
|
1951
1976
|
return annotation_node.attr in COLLECTION_TYPE_NAMES
|
|
1977
|
+
if isinstance(annotation_node, ast.BinOp) and isinstance(annotation_node.op, ast.BitOr):
|
|
1978
|
+
return (
|
|
1979
|
+
_annotation_names_collection(annotation_node.left)
|
|
1980
|
+
or _annotation_names_collection(annotation_node.right)
|
|
1981
|
+
)
|
|
1982
|
+
if isinstance(annotation_node, ast.Subscript):
|
|
1983
|
+
outer_value = annotation_node.value
|
|
1984
|
+
is_optional_or_union_subscript = (
|
|
1985
|
+
(isinstance(outer_value, ast.Name) and outer_value.id in UNION_TYPING_NAMES)
|
|
1986
|
+
or (isinstance(outer_value, ast.Attribute) and outer_value.attr in UNION_TYPING_NAMES)
|
|
1987
|
+
)
|
|
1988
|
+
if is_optional_or_union_subscript:
|
|
1989
|
+
slice_node = annotation_node.slice
|
|
1990
|
+
if isinstance(slice_node, ast.Tuple):
|
|
1991
|
+
return any(
|
|
1992
|
+
_annotation_names_collection(each_element)
|
|
1993
|
+
for each_element in slice_node.elts
|
|
1994
|
+
)
|
|
1995
|
+
return _annotation_names_collection(slice_node)
|
|
1996
|
+
return _annotation_names_collection(outer_value)
|
|
1952
1997
|
return False
|
|
1953
1998
|
|
|
1954
1999
|
|
|
@@ -1983,12 +2028,10 @@ def check_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
|
1983
2028
|
issues.append(
|
|
1984
2029
|
f"Line {target_line}: Collection constant {target_name} - prefix with ALL_ (CODE_RULES §5)"
|
|
1985
2030
|
)
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
for node in ast.walk(tree):
|
|
1989
|
-
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2031
|
+
for each_walked_node in ast.walk(tree):
|
|
2032
|
+
if not isinstance(each_walked_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1990
2033
|
continue
|
|
1991
|
-
for each_arg in _collect_annotated_arguments(
|
|
2034
|
+
for each_arg in _collect_annotated_arguments(each_walked_node):
|
|
1992
2035
|
if not _annotation_names_collection(each_arg.annotation):
|
|
1993
2036
|
continue
|
|
1994
2037
|
if each_arg.arg in {"self", "cls"}:
|
|
@@ -1998,8 +2041,369 @@ def check_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
|
1998
2041
|
issues.append(
|
|
1999
2042
|
f"Line {each_arg.lineno}: Collection parameter {each_arg.arg} - prefix with all_ (CODE_RULES §5)"
|
|
2000
2043
|
)
|
|
2001
|
-
|
|
2002
|
-
|
|
2044
|
+
return issues
|
|
2045
|
+
|
|
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
|
|
2003
2407
|
return issues
|
|
2004
2408
|
|
|
2005
2409
|
|
|
@@ -2035,8 +2439,222 @@ def check_library_print(content: str, file_path: str) -> list[str]:
|
|
|
2035
2439
|
issues.append(
|
|
2036
2440
|
f"Line {node.lineno}: sys.{value_node.attr}.write - route through logger"
|
|
2037
2441
|
)
|
|
2038
|
-
|
|
2039
|
-
|
|
2442
|
+
return issues
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
SELF_AND_CLS_PARAMETER_NAMES: frozenset[str] = frozenset({"self", "cls"})
|
|
2446
|
+
LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
|
|
2447
|
+
EACH_PREFIX = "each_"
|
|
2448
|
+
BARE_EACH_TOKEN = "each"
|
|
2449
|
+
INLINE_COLLECTION_MIN_LENGTH = 3
|
|
2450
|
+
ALL_CAPS_WITH_UNDERSCORE_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$")
|
|
2451
|
+
DOTTED_SEGMENT_PATTERN = re.compile(r"^\.[a-z][a-z0-9_]*$")
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def _is_magic_string_literal(string_value: str) -> bool:
|
|
2455
|
+
if not string_value:
|
|
2456
|
+
return False
|
|
2457
|
+
if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(string_value):
|
|
2458
|
+
return True
|
|
2459
|
+
if DOTTED_SEGMENT_PATTERN.match(string_value):
|
|
2460
|
+
return True
|
|
2461
|
+
return False
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def _collect_docstring_node_ids(tree: ast.Module) -> set[int]:
|
|
2465
|
+
docstring_ids: set[int] = set()
|
|
2466
|
+
docstring_owner_node_types = (
|
|
2467
|
+
ast.Module,
|
|
2468
|
+
ast.FunctionDef,
|
|
2469
|
+
ast.AsyncFunctionDef,
|
|
2470
|
+
ast.ClassDef,
|
|
2471
|
+
)
|
|
2472
|
+
for node in ast.walk(tree):
|
|
2473
|
+
if not isinstance(node, docstring_owner_node_types):
|
|
2474
|
+
continue
|
|
2475
|
+
if not node.body:
|
|
2476
|
+
continue
|
|
2477
|
+
first_statement = node.body[0]
|
|
2478
|
+
if not isinstance(first_statement, ast.Expr):
|
|
2479
|
+
continue
|
|
2480
|
+
first_value = first_statement.value
|
|
2481
|
+
if isinstance(first_value, ast.Constant) and isinstance(first_value.value, str):
|
|
2482
|
+
docstring_ids.add(id(first_value))
|
|
2483
|
+
return docstring_ids
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def _collect_fstring_part_node_ids(tree: ast.Module) -> set[int]:
|
|
2487
|
+
fstring_part_ids: set[int] = set()
|
|
2488
|
+
for node in ast.walk(tree):
|
|
2489
|
+
if not isinstance(node, ast.JoinedStr):
|
|
2490
|
+
continue
|
|
2491
|
+
for each_value in node.values:
|
|
2492
|
+
if isinstance(each_value, ast.Constant) and isinstance(each_value.value, str):
|
|
2493
|
+
fstring_part_ids.add(id(each_value))
|
|
2494
|
+
return fstring_part_ids
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def _walk_skipping_nested_function_defs(start_node: ast.AST) -> Iterator[ast.AST]:
|
|
2498
|
+
if isinstance(start_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2499
|
+
return
|
|
2500
|
+
nodes_to_visit: list[ast.AST] = [start_node]
|
|
2501
|
+
while nodes_to_visit:
|
|
2502
|
+
current_node = nodes_to_visit.pop()
|
|
2503
|
+
yield current_node
|
|
2504
|
+
all_child_nodes = list(ast.iter_child_nodes(current_node))
|
|
2505
|
+
for each_child_node in reversed(all_child_nodes):
|
|
2506
|
+
if isinstance(each_child_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2507
|
+
continue
|
|
2508
|
+
nodes_to_visit.append(each_child_node)
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def check_string_literal_magic(content: str, file_path: str) -> list[str]:
|
|
2512
|
+
if is_test_file(file_path):
|
|
2513
|
+
return []
|
|
2514
|
+
if is_config_file(file_path):
|
|
2515
|
+
return []
|
|
2516
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2517
|
+
return []
|
|
2518
|
+
try:
|
|
2519
|
+
tree = ast.parse(content)
|
|
2520
|
+
except SyntaxError:
|
|
2521
|
+
return []
|
|
2522
|
+
docstring_node_ids = _collect_docstring_node_ids(tree)
|
|
2523
|
+
fstring_part_node_ids = _collect_fstring_part_node_ids(tree)
|
|
2524
|
+
issues: list[str] = []
|
|
2525
|
+
flagged_node_ids: set[int] = set()
|
|
2526
|
+
for function_node in ast.walk(tree):
|
|
2527
|
+
if not isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2528
|
+
continue
|
|
2529
|
+
for each_body_statement in function_node.body:
|
|
2530
|
+
for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
|
|
2531
|
+
if not isinstance(each_descendant, ast.Constant):
|
|
2532
|
+
continue
|
|
2533
|
+
if not isinstance(each_descendant.value, str):
|
|
2534
|
+
continue
|
|
2535
|
+
if id(each_descendant) in flagged_node_ids:
|
|
2536
|
+
continue
|
|
2537
|
+
if id(each_descendant) in docstring_node_ids:
|
|
2538
|
+
continue
|
|
2539
|
+
if id(each_descendant) in fstring_part_node_ids:
|
|
2540
|
+
continue
|
|
2541
|
+
if not _is_magic_string_literal(each_descendant.value):
|
|
2542
|
+
continue
|
|
2543
|
+
flagged_node_ids.add(id(each_descendant))
|
|
2544
|
+
issues.append(
|
|
2545
|
+
f"Line {each_descendant.lineno}: string magic value {each_descendant.value!r}"
|
|
2546
|
+
f" - extract to config/"
|
|
2547
|
+
)
|
|
2548
|
+
return issues
|
|
2549
|
+
|
|
2550
|
+
|
|
2551
|
+
def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
|
|
2552
|
+
if is_test_file(file_path):
|
|
2553
|
+
return []
|
|
2554
|
+
if is_config_file(file_path):
|
|
2555
|
+
return []
|
|
2556
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2557
|
+
return []
|
|
2558
|
+
try:
|
|
2559
|
+
tree = ast.parse(content)
|
|
2560
|
+
except SyntaxError:
|
|
2561
|
+
return []
|
|
2562
|
+
issues: list[str] = []
|
|
2563
|
+
flagged_node_ids: set[int] = set()
|
|
2564
|
+
for function_node in ast.walk(tree):
|
|
2565
|
+
if not isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2566
|
+
continue
|
|
2567
|
+
for each_body_statement in function_node.body:
|
|
2568
|
+
for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
|
|
2569
|
+
if not isinstance(each_descendant, (ast.Set, ast.List)):
|
|
2570
|
+
continue
|
|
2571
|
+
if id(each_descendant) in flagged_node_ids:
|
|
2572
|
+
continue
|
|
2573
|
+
all_elements = each_descendant.elts
|
|
2574
|
+
if len(all_elements) < INLINE_COLLECTION_MIN_LENGTH:
|
|
2575
|
+
continue
|
|
2576
|
+
if not all(isinstance(each_element, ast.Constant) for each_element in all_elements):
|
|
2577
|
+
continue
|
|
2578
|
+
flagged_node_ids.add(id(each_descendant))
|
|
2579
|
+
collection_kind = "set" if isinstance(each_descendant, ast.Set) else "list"
|
|
2580
|
+
issues.append(
|
|
2581
|
+
f"Line {each_descendant.lineno}: inline {collection_kind} literal of {len(all_elements)}"
|
|
2582
|
+
f" constants in function body - extract to config/"
|
|
2583
|
+
)
|
|
2584
|
+
return issues
|
|
2585
|
+
|
|
2586
|
+
|
|
2587
|
+
def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
|
|
2588
|
+
if is_test_file(file_path):
|
|
2589
|
+
return []
|
|
2590
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2591
|
+
return []
|
|
2592
|
+
try:
|
|
2593
|
+
tree = ast.parse(content)
|
|
2594
|
+
except SyntaxError:
|
|
2595
|
+
return []
|
|
2596
|
+
issues: list[str] = []
|
|
2597
|
+
for node in ast.walk(tree):
|
|
2598
|
+
if not isinstance(node, (ast.For, ast.AsyncFor)):
|
|
2599
|
+
continue
|
|
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
|
|
2612
|
+
issues.append(
|
|
2613
|
+
f"Line {each_name_node.lineno}: loop variable {target_name!r} - prefix with each_ (CODE_RULES §5)"
|
|
2614
|
+
)
|
|
2615
|
+
return issues
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
def check_parameter_annotations(content: str, file_path: str) -> list[str]:
|
|
2619
|
+
if is_test_file(file_path):
|
|
2620
|
+
return []
|
|
2621
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2622
|
+
return []
|
|
2623
|
+
try:
|
|
2624
|
+
tree = ast.parse(content)
|
|
2625
|
+
except SyntaxError:
|
|
2626
|
+
return []
|
|
2627
|
+
issues: list[str] = []
|
|
2628
|
+
for node in ast.walk(tree):
|
|
2629
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2630
|
+
continue
|
|
2631
|
+
for each_arg in _collect_annotated_arguments(node):
|
|
2632
|
+
if each_arg.arg in SELF_AND_CLS_PARAMETER_NAMES:
|
|
2633
|
+
continue
|
|
2634
|
+
if each_arg.annotation is None:
|
|
2635
|
+
issues.append(
|
|
2636
|
+
f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on {node.name!r} missing type annotation (CODE_RULES §6)"
|
|
2637
|
+
)
|
|
2638
|
+
return issues
|
|
2639
|
+
|
|
2640
|
+
|
|
2641
|
+
def check_return_annotations(content: str, file_path: str) -> list[str]:
|
|
2642
|
+
if is_test_file(file_path):
|
|
2643
|
+
return []
|
|
2644
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
2645
|
+
return []
|
|
2646
|
+
try:
|
|
2647
|
+
tree = ast.parse(content)
|
|
2648
|
+
except SyntaxError:
|
|
2649
|
+
return []
|
|
2650
|
+
issues: list[str] = []
|
|
2651
|
+
for node in ast.walk(tree):
|
|
2652
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2653
|
+
continue
|
|
2654
|
+
if node.returns is None:
|
|
2655
|
+
issues.append(
|
|
2656
|
+
f"Line {node.lineno}: function {node.name!r} missing return type annotation (CODE_RULES §6)"
|
|
2657
|
+
)
|
|
2040
2658
|
return issues
|
|
2041
2659
|
|
|
2042
2660
|
|
|
@@ -2061,8 +2679,7 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
2061
2679
|
all_issues.extend(check_magic_values(content, file_path))
|
|
2062
2680
|
all_issues.extend(check_fstring_structural_literals(content, file_path))
|
|
2063
2681
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
2064
|
-
|
|
2065
|
-
print(f"[CODE_RULES advisory] {file_path}: {each_advisory}", file=sys.stderr)
|
|
2682
|
+
all_issues.extend(check_constants_outside_config_advisory(content, file_path))
|
|
2066
2683
|
all_issues.extend(check_file_global_constants_use_count(content, file_path))
|
|
2067
2684
|
all_issues.extend(check_type_escape_hatches(content, file_path))
|
|
2068
2685
|
all_issues.extend(check_banned_identifiers(content, file_path))
|
|
@@ -2072,7 +2689,16 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
2072
2689
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
2073
2690
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
2074
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))
|
|
2075
2696
|
all_issues.extend(check_library_print(content, file_path))
|
|
2697
|
+
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
2698
|
+
all_issues.extend(check_return_annotations(content, file_path))
|
|
2699
|
+
all_issues.extend(check_loop_variable_naming(content, file_path))
|
|
2700
|
+
all_issues.extend(check_inline_literal_collections(content, file_path))
|
|
2701
|
+
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
2076
2702
|
check_incomplete_mocks(content, file_path)
|
|
2077
2703
|
check_duplicated_format_patterns(content, file_path)
|
|
2078
2704
|
|
|
@@ -2143,4 +2769,4 @@ def main() -> None:
|
|
|
2143
2769
|
|
|
2144
2770
|
|
|
2145
2771
|
if __name__ == "__main__":
|
|
2146
|
-
main()
|
|
2772
|
+
main()
|