claude-dev-env 1.35.0 → 1.36.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.
Files changed (115) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/bin/install.mjs +28 -8
  3. package/bin/install.test.mjs +9 -1
  4. package/docs/CODE_RULES.md +3 -0
  5. package/docs/agents-md-alignment-plan.md +123 -0
  6. package/hooks/blocking/code_rules_enforcer.py +451 -39
  7. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  8. package/hooks/blocking/test_code_rules_enforcer.py +182 -0
  9. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  10. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  11. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
  12. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
  13. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  14. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
  15. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
  16. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  17. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  18. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  19. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  20. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  21. package/hooks/config/banned_identifiers_constants.py +24 -0
  22. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  23. package/hooks/config/hook_log_extractor_constants.py +1 -1
  24. package/hooks/config/pre_tool_use_stdin.py +48 -0
  25. package/hooks/config/setup_project_paths_constants.py +4 -0
  26. package/hooks/config/stuttering_check_config.py +14 -0
  27. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  28. package/hooks/config/sys_path_insert_constants.py +4 -0
  29. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  30. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  31. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  32. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  33. package/hooks/config/unused_module_import_constants.py +7 -0
  34. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  35. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  36. package/hooks/git-hooks/config.py +3 -3
  37. package/hooks/git-hooks/test_gate_utils.py +10 -10
  38. package/hooks/mypy.ini +2 -0
  39. package/package.json +1 -1
  40. package/rules/gh-paginate.md +125 -0
  41. package/skills/bugteam/CONSTRAINTS.md +12 -6
  42. package/skills/bugteam/SKILL.md +364 -154
  43. package/skills/bugteam/SKILL_EVALS.md +25 -23
  44. package/skills/bugteam/reference/README.md +2 -0
  45. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  46. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  47. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  48. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  49. package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
  50. package/skills/bugteam/test_skill_additions.py +13 -4
  51. package/skills/bugteam/test_team_lifecycle.py +103 -0
  52. package/skills/findbugs/SKILL.md +3 -3
  53. package/skills/fixbugs/SKILL.md +4 -4
  54. package/skills/monitor-open-prs/SKILL.md +32 -2
  55. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  56. package/skills/pr-converge/SKILL.md +1206 -131
  57. package/skills/pr-converge/scripts/README.md +145 -0
  58. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  59. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  60. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  61. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  62. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  63. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  64. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  65. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  66. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  67. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  68. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  69. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  70. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  71. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  72. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  73. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  74. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  75. package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
  76. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  77. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  78. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  79. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  80. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  81. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  82. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  83. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  84. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  85. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  86. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  87. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  88. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  89. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  90. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  91. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  92. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  93. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  94. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  95. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  96. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  97. package/skills/pr-converge/test_team_lifecycle.py +56 -0
  98. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  99. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  100. package/skills/qbug/SKILL.md +4 -4
  101. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  102. package/skills/resume-review/SKILL.md +261 -0
  103. package/skills/bugteam/scripts/README.md +0 -58
  104. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  105. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  106. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  107. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  108. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  109. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  110. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  111. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  112. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  113. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  114. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  115. /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
- BANNED_IDENTIFIERS: frozenset[str] = frozenset({"result", "data", "output", "response", "value", "item", "temp"})
859
- MAX_BANNED_IDENTIFIER_ISSUES: int = 3
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
- if target.id in BANNED_IDENTIFIERS:
870
- return [target]
871
- return []
890
+ return [target]
872
891
  if isinstance(target, (ast.Tuple, ast.List)):
873
- banned_names: list[ast.Name] = []
892
+ names: list[ast.Name] = []
874
893
  for each_element in target.elts:
875
- banned_names.extend(_collect_banned_names_from_target(each_element))
876
- return banned_names
894
+ names.extend(_collect_target_names(each_element))
895
+ return names
877
896
  if isinstance(target, ast.Starred):
878
- return _collect_banned_names_from_target(target.value)
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
- return _collect_banned_names_from_target(node.target)
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
- return _collect_banned_names_from_target(node.target)
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 node in ast.walk(tree):
1985
- 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)):
1986
2033
  continue
1987
- for each_arg in _collect_annotated_arguments(node):
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
- target = node.target
2191
- if not isinstance(target, ast.Name):
2192
- continue
2193
- target_name = target.id
2194
- if target_name in LOOP_INDEX_LETTER_EXEMPTIONS:
2195
- continue
2196
- if target_name == BARE_EACH_TOKEN:
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 {target.lineno}: loop variable 'each' is a bare token without subject"
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 = json.load(sys.stdin)
139
- tool_name = hook_input.get("tool_name", "")
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
- tool_input = hook_input.get("tool_input", {})
143
- command = tool_input.get("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()