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.
Files changed (148) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/agents/docs-agent.md +1 -1
  3. package/agents/project-docs-analyzer.md +0 -1
  4. package/agents/skill-to-agent-converter.md +0 -1
  5. package/bin/install.mjs +28 -8
  6. package/bin/install.test.mjs +9 -1
  7. package/commands/initialize.md +0 -1
  8. package/commands/readability-review.md +4 -4
  9. package/commands/review-plan.md +2 -4
  10. package/commands/stubcheck.md +1 -2
  11. package/docs/CODE_RULES.md +3 -0
  12. package/docs/agents-md-alignment-plan.md +123 -0
  13. package/hooks/blocking/code_rules_enforcer.py +686 -60
  14. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  15. package/hooks/blocking/test_code_rules_enforcer.py +273 -39
  16. package/hooks/blocking/test_code_rules_enforcer_annotations.py +97 -0
  17. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  18. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  19. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +328 -0
  20. package/hooks/blocking/test_code_rules_enforcer_config_path.py +0 -20
  21. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +33 -11
  22. package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +0 -18
  23. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  24. package/hooks/blocking/test_code_rules_enforcer_inline_literal_collections.py +155 -0
  25. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +194 -0
  26. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -13
  27. package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +0 -26
  28. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +234 -0
  29. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  30. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  31. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  32. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  33. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  34. package/hooks/config/banned_identifiers_constants.py +24 -0
  35. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  36. package/hooks/config/hook_log_extractor_constants.py +1 -1
  37. package/hooks/config/pre_tool_use_stdin.py +48 -0
  38. package/hooks/config/setup_project_paths_constants.py +4 -0
  39. package/hooks/config/stuttering_check_config.py +14 -0
  40. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  41. package/hooks/config/sys_path_insert_constants.py +4 -0
  42. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  43. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  44. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  45. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  46. package/hooks/config/unused_module_import_constants.py +7 -0
  47. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  48. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  49. package/hooks/git-hooks/config.py +3 -3
  50. package/hooks/git-hooks/test_gate_utils.py +10 -10
  51. package/hooks/mypy.ini +2 -0
  52. package/package.json +1 -1
  53. package/rules/gh-paginate.md +125 -0
  54. package/skills/bugteam/CONSTRAINTS.md +12 -6
  55. package/skills/bugteam/PROMPTS.md +0 -39
  56. package/skills/bugteam/SKILL.md +93 -125
  57. package/skills/bugteam/SKILL_EVALS.md +25 -23
  58. package/skills/bugteam/reference/README.md +2 -0
  59. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  60. package/skills/bugteam/reference/copilot-gap-analysis.md +12 -0
  61. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  62. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  63. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  64. package/skills/bugteam/test_skill_additions.py +13 -4
  65. package/skills/bugteam/test_team_lifecycle.py +94 -0
  66. package/skills/findbugs/SKILL.md +3 -3
  67. package/skills/fixbugs/SKILL.md +4 -4
  68. package/skills/monitor-open-prs/SKILL.md +32 -2
  69. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  70. package/skills/pr-converge/SKILL.md +576 -95
  71. package/skills/pr-converge/scripts/README.md +145 -0
  72. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  73. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  74. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  75. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  76. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  77. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  78. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  79. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  80. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  81. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  82. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  83. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  84. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  85. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  86. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  87. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  88. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  89. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  90. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  91. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  92. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  93. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  94. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  95. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  96. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  97. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  98. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  99. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  100. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  101. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  102. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  103. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  104. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  105. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  106. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  107. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  108. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  109. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  110. package/skills/pr-converge/test_team_lifecycle.py +47 -0
  111. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  112. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  113. package/skills/qbug/SKILL.md +4 -4
  114. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  115. package/skills/resume-review/SKILL.md +261 -0
  116. package/agents/agent-writer.md +0 -157
  117. package/agents/config-centralizer.md +0 -686
  118. package/agents/config-extraction-agent.md +0 -225
  119. package/agents/doc-orchestrator.md +0 -47
  120. package/agents/docx-agent.md +0 -211
  121. package/agents/magic-value-eliminator-agent.md +0 -72
  122. package/agents/mandatory-agent-workflow-agent.md +0 -88
  123. package/agents/parallel-workflow-coordinator.md +0 -779
  124. package/agents/pdf-agent.md +0 -302
  125. package/agents/project-context-loader.md +0 -238
  126. package/agents/readability-review-agent.md +0 -76
  127. package/agents/refactoring-specialist.md +0 -69
  128. package/agents/right-sized-engineer.md +0 -129
  129. package/agents/session-continuity-manager.md +0 -53
  130. package/agents/stub-detector-agent.md +0 -140
  131. package/agents/tdd-test-writer.md +0 -62
  132. package/agents/test-data-builder.md +0 -68
  133. package/agents/tooling-builder.md +0 -78
  134. package/agents/validation-expert.md +0 -71
  135. package/agents/xlsx-agent.md +0 -169
  136. package/skills/bugteam/scripts/README.md +0 -58
  137. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  138. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  139. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  140. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  141. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  142. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  143. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  144. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  145. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  146. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  147. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  148. /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,})\s*=\s*[^=]")
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,})\s*=\s*[^=]")
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
- BANNED_IDENTIFIERS: frozenset[str] = frozenset({"result", "data", "output", "response", "value", "item", "temp"})
870
- MAX_BANNED_IDENTIFIER_ISSUES: int = 3
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
- if target.id in BANNED_IDENTIFIERS:
881
- return [target]
882
- return []
890
+ return [target]
883
891
  if isinstance(target, (ast.Tuple, ast.List)):
884
- banned_names: list[ast.Name] = []
892
+ names: list[ast.Name] = []
885
893
  for each_element in target.elts:
886
- banned_names.extend(_collect_banned_names_from_target(each_element))
887
- return banned_names
894
+ names.extend(_collect_target_names(each_element))
895
+ return names
888
896
  if isinstance(target, ast.Starred):
889
- return _collect_banned_names_from_target(target.value)
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
- 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)
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
- 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)
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
- if len(issues) >= MAX_ISSUES_PER_CHECK:
1987
- break
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(node):
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
- if len(issues) >= MAX_ISSUES_PER_CHECK:
2002
- return issues
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
- if len(issues) >= MAX_ISSUES_PER_CHECK:
2039
- break
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
- for each_advisory in check_constants_outside_config_advisory(content, file_path):
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()