claude-dev-env 1.38.0 → 1.39.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 (271) hide show
  1. package/CLAUDE.md +10 -36
  2. package/_shared/pr-loop/audit-reply-template.md +147 -0
  3. package/_shared/pr-loop/fix-protocol.md +25 -4
  4. package/_shared/pr-loop/gh-payloads.md +37 -50
  5. package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
  6. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +189 -0
  7. package/_shared/pr-loop/scripts/post_audit_thread.py +947 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  9. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +923 -0
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  11. package/_shared/pr-loop/state-schema.md +1 -1
  12. package/agents/clean-coder.md +2 -2
  13. package/bin/install.mjs +6 -7
  14. package/bin/install.test.mjs +8 -0
  15. package/commands/doc-gist.md +16 -0
  16. package/commands/plan.md +0 -2
  17. package/commands/review-plan.md +1 -1
  18. package/docs/CODE_RULES.md +122 -2
  19. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  20. package/hooks/blocking/code_rules_enforcer.py +1236 -161
  21. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  22. package/hooks/blocking/destructive_command_blocker.py +74 -0
  23. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  24. package/hooks/blocking/md_to_html_blocker.py +119 -0
  25. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  26. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  27. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  28. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  29. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  30. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  31. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  32. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  33. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  34. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  36. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  37. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  38. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  39. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  40. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  41. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  42. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +158 -0
  43. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  44. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  45. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  46. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  47. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  48. package/hooks/config/any_type_config.py +7 -0
  49. package/hooks/config/banned_identifiers_constants.py +11 -0
  50. package/hooks/config/blocking_check_limits.py +38 -0
  51. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  52. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  53. package/hooks/config/convergence_branch_constants.py +9 -0
  54. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  55. package/hooks/config/html_companion_constants.py +20 -0
  56. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  57. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  58. package/hooks/hooks.json +28 -20
  59. package/hooks/pyproject.toml +69 -0
  60. package/hooks/validators/mypy_integration.py +47 -1
  61. package/hooks/validators/run_all_validators.py +3 -3
  62. package/hooks/validators/test_mypy_integration.py +50 -1
  63. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  64. package/hooks/workflow/md_to_html_companion.py +365 -0
  65. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  66. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  67. package/package.json +1 -1
  68. package/rules/gh-body-file.md +2 -0
  69. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  70. package/scripts/check.ps1 +106 -0
  71. package/scripts/config/timing.py +11 -0
  72. package/scripts/sweep_empty_dirs.py +138 -0
  73. package/scripts/sync_to_cursor/rules.py +1 -1
  74. package/scripts/test_sweep_empty_dirs.py +183 -0
  75. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  76. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  77. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  78. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  79. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  80. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  81. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  82. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  83. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  84. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  85. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  86. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  87. package/skills/bugteam/CONSTRAINTS.md +21 -22
  88. package/skills/bugteam/EXAMPLES.md +3 -3
  89. package/skills/bugteam/PROMPTS.md +227 -67
  90. package/skills/bugteam/SKILL.md +114 -455
  91. package/skills/bugteam/reference/README.md +1 -1
  92. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  93. package/skills/bugteam/reference/audit-contract.md +4 -22
  94. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  95. package/skills/bugteam/reference/design-rationale.md +2 -2
  96. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  97. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  98. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  99. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  100. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  101. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  102. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  103. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  104. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  105. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  106. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  107. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  108. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  109. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  112. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  113. package/skills/bugteam/reference/team-setup.md +106 -9
  114. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  115. package/skills/bugteam/scripts/README.md +60 -0
  116. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  117. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  118. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  119. package/skills/bugteam/scripts/bugteam_preflight.py +294 -0
  120. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  121. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  122. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  123. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  124. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  125. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  126. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  127. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  128. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  129. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  130. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  131. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  132. package/skills/bugteam/scripts/test_bugteam_preflight.py +268 -0
  133. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  134. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  135. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  136. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  137. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  138. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  139. package/skills/bugteam/test_skill_additions.py +1 -11
  140. package/skills/code/SKILL.md +176 -0
  141. package/skills/doc-gist/SKILL.md +99 -0
  142. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  143. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  144. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  145. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  146. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  147. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  148. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  149. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  150. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  151. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  152. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  153. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  154. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  155. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  156. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  157. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  158. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  159. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  160. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  161. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  162. package/skills/doc-gist/references/examples/README.md +5 -0
  163. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  164. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  165. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  166. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  167. package/skills/findbugs/SKILL.md +68 -2
  168. package/skills/monitor-open-prs/SKILL.md +13 -32
  169. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  170. package/skills/pr-consistency-audit/SKILL.md +112 -0
  171. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  172. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  173. package/skills/pr-converge/SKILL.md +227 -23
  174. package/skills/pr-converge/config/__init__.py +0 -0
  175. package/skills/pr-converge/config/constants.py +62 -0
  176. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  177. package/skills/pr-converge/reference/examples.md +43 -11
  178. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  179. package/skills/pr-converge/reference/ground-rules.md +5 -3
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  181. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  182. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  183. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  184. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  185. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  186. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  187. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  188. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  189. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  190. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  191. package/skills/pr-converge/reference/per-tick.md +90 -31
  192. package/skills/pr-converge/reference/state-schema.md +22 -1
  193. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  194. package/skills/pr-converge/scripts/README.md +34 -46
  195. package/skills/pr-converge/scripts/check_bugbot_ci.py +174 -0
  196. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  197. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  198. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  199. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  200. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  201. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  202. package/skills/qbug/SKILL.md +132 -27
  203. package/skills/session-log/SKILL.md +216 -114
  204. package/skills/session-tidy/SKILL.md +1 -1
  205. package/skills/skill-builder/SKILL.md +138 -56
  206. package/skills/skill-builder/references/delegation-map.md +72 -113
  207. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  208. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  209. package/skills/skill-builder/references/skill-types.md +228 -0
  210. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  211. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  212. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  213. package/skills/skill-builder/workflows/new-skill.md +80 -168
  214. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  215. package/skills/structure-prompt/SKILL.md +50 -0
  216. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  217. package/skills/structure-prompt/reference/block-classification.md +27 -0
  218. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  219. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  220. package/skills/structure-prompt/reference/cleanup.md +33 -0
  221. package/skills/structure-prompt/reference/constraints.md +33 -0
  222. package/skills/structure-prompt/reference/directives.md +37 -0
  223. package/skills/structure-prompt/reference/examples.md +72 -0
  224. package/skills/structure-prompt/reference/instantiation.md +51 -0
  225. package/skills/structure-prompt/reference/output-contract.md +72 -0
  226. package/skills/structure-prompt/reference/per-category.md +23 -0
  227. package/skills/structure-prompt/reference/persona.md +38 -0
  228. package/skills/structure-prompt/reference/research.md +33 -0
  229. package/skills/structure-prompt/reference/structure.md +28 -0
  230. package/agents/code-standards-agent.md +0 -93
  231. package/agents/groq-coder.md +0 -113
  232. package/agents/plan-executor.md +0 -226
  233. package/agents/project-docs-analyzer.md +0 -53
  234. package/agents/project-structure-organizer-agent.md +0 -72
  235. package/agents/skill-to-agent-converter.md +0 -370
  236. package/agents/skill-writer-agent.md +0 -470
  237. package/agents/user-docs-writer.md +0 -67
  238. package/agents/workflow-visual-documenter.md +0 -82
  239. package/commands/readability-review.md +0 -20
  240. package/hooks/mypy.ini +0 -2
  241. package/hooks/notification/attention_needed_notify.py +0 -71
  242. package/hooks/notification/claude_notification_handler.py +0 -67
  243. package/hooks/notification/notification_utils.py +0 -267
  244. package/hooks/notification/subagent_complete_notify.py +0 -381
  245. package/hooks/notification/test_attention_needed_notify.py +0 -47
  246. package/hooks/notification/test_claude_notification_handler.py +0 -54
  247. package/hooks/notification/test_notification_utils.py +0 -91
  248. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  249. package/scripts/config/groq_bugteam_config.py +0 -230
  250. package/scripts/config/test_groq_bugteam_config.py +0 -83
  251. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  252. package/scripts/groq_bugteam.README.md +0 -131
  253. package/scripts/groq_bugteam.py +0 -647
  254. package/scripts/groq_bugteam_dotenv.py +0 -40
  255. package/scripts/groq_bugteam_spec.py +0 -226
  256. package/scripts/test_groq_bugteam.py +0 -529
  257. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  258. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  259. package/scripts/test_groq_bugteam_spec.py +0 -338
  260. package/skills/bugteam/SKILL_EVALS.md +0 -309
  261. package/skills/dream/SKILL.md +0 -118
  262. package/skills/ingest/SKILL.md +0 -40
  263. package/skills/npm-creator/SKILL.md +0 -187
  264. package/skills/readability-review/SKILL.md +0 -127
  265. package/skills/resume-review/SKILL.md +0 -261
  266. package/skills/rule-audit/SKILL.md +0 -307
  267. package/skills/rule-creator/SKILL.md +0 -150
  268. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  269. package/skills/skill-writer/REFERENCE.md +0 -284
  270. package/skills/skill-writer/SKILL.md +0 -222
  271. package/skills/tdd-team/SKILL.md +0 -128
@@ -51,6 +51,13 @@ from config.hardcoded_user_path_constants import ( # noqa: E402
51
51
  HARDCODED_USER_PATH_PATTERN,
52
52
  MAX_HARDCODED_USER_PATH_ISSUES,
53
53
  )
54
+ from config.inline_tuple_string_magic_constants import ( # noqa: E402
55
+ ALL_SNAKE_CASE_KEYWORD_EXEMPTIONS,
56
+ EXPECTED_TUPLE_PAIR_LENGTH,
57
+ INLINE_TUPLE_STRING_MAGIC_MESSAGE_SUFFIX,
58
+ MAX_INLINE_TUPLE_STRING_MAGIC_ISSUES,
59
+ SNAKE_CASE_LITERAL_PATTERN,
60
+ )
54
61
  from config.stuttering_check_config import ( # noqa: E402
55
62
  MAX_STUTTERING_PREFIX_ISSUES,
56
63
  STUTTERING_ALL_PREFIX_PATTERN,
@@ -68,34 +75,57 @@ from config.stuttering_import_binding_constants import ( # noqa: E402
68
75
  MODULE_PATH_SEPARATOR,
69
76
  WILDCARD_IMPORT_SENTINEL,
70
77
  )
78
+ from config.any_type_config import ALL_ANY_ALLOWED_PATTERNS # noqa: E402
79
+ from config.blocking_check_limits import ( # noqa: E402
80
+ ALL_BANNED_PREFIX_NAMES,
81
+ ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES,
82
+ ALL_BOUNDARY_TYPE_EXEMPT_FILENAMES,
83
+ ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
84
+ ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
85
+ ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES,
86
+ DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
87
+ MAX_BANNED_PREFIX_ISSUES,
88
+ MAX_BARE_EXCEPT_ISSUES,
89
+ MAX_BOUNDARY_TYPE_ISSUES,
90
+ MAX_DOCSTRING_FORMAT_ISSUES,
91
+ MAX_STUB_IMPLEMENTATION_ISSUES,
92
+ MAX_TEST_BRANCHING_ISSUES,
93
+ MAX_TYPED_DICT_PAIR_ISSUES,
94
+ MAX_TYPE_ESCAPE_HATCH_ISSUES,
95
+ MAX_THIN_WRAPPER_ISSUES,
96
+ )
71
97
 
72
- PYTHON_EXTENSIONS = {".py"}
73
- JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
74
- ALL_CODE_EXTENSIONS = PYTHON_EXTENSIONS | JAVASCRIPT_EXTENSIONS
75
-
76
- TEST_PATH_PATTERNS = {"test_", "_test.", ".test.", ".spec.", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
77
- HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/", "/packages/claude-dev-env/hooks/", "\\packages\\claude-dev-env\\hooks\\"}
78
- WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
79
- MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
80
-
81
- ADVISORY_LINE_THRESHOLD_SOFT = 400
82
- ADVISORY_LINE_THRESHOLD_HARD = 1000
83
-
84
- BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_")
85
- UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
86
-
87
-
88
- TYPE_CHECKING_BLOCK_PATTERN = re.compile(r"^(?P<indent>\s*)if\s+(typing\.)?TYPE_CHECKING\s*:\s*$")
89
- IMPORT_STATEMENT_PREFIXES: tuple[str, ...] = ("import ", "from ")
90
- NOT_INSIDE_TYPE_CHECKING_BLOCK = -1
91
- FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
92
-
93
- COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
94
- "list", "tuple", "set", "frozenset", "dict",
95
- "Iterable", "Sequence", "Mapping", "MutableMapping", "FrozenSet",
96
- })
97
- COLLECTION_BY_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*_by_[a-z][a-z0-9_]*$")
98
- CLI_FILE_PATH_MARKERS: tuple[str, ...] = ("/scripts/", "\\scripts\\", "_cli.py", "/cli.py", "\\cli.py")
98
+ from config.code_rules_enforcer_constants import ( # noqa: E402
99
+ ADVISORY_LINE_THRESHOLD_HARD,
100
+ ADVISORY_LINE_THRESHOLD_SOFT,
101
+ ALL_CODE_EXTENSIONS,
102
+ ALL_CAPS_WITH_UNDERSCORE_PATTERN,
103
+ BARE_EACH_TOKEN,
104
+ ALL_BOOLEAN_NAME_PREFIXES,
105
+ ALL_BUILTIN_DICT_METHOD_NAMES,
106
+ ALL_CLI_FILE_PATH_MARKERS,
107
+ COLLECTION_BY_NAME_PATTERN,
108
+ ALL_COLLECTION_TYPE_NAMES,
109
+ ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES,
110
+ DOTTED_SEGMENT_PATTERN,
111
+ EACH_PREFIX,
112
+ FILE_GLOBAL_UPPER_SNAKE_PATTERN,
113
+ ALL_HOOK_INFRASTRUCTURE_PATTERNS,
114
+ ALL_IMPORT_STATEMENT_PREFIXES,
115
+ INLINE_COLLECTION_MIN_LENGTH,
116
+ ALL_JAVASCRIPT_EXTENSIONS,
117
+ LOGGING_FSTRING_PATTERN,
118
+ ALL_LOOP_INDEX_LETTER_EXEMPTIONS,
119
+ ALL_MIGRATION_PATH_PATTERNS,
120
+ NOT_INSIDE_TYPE_CHECKING_BLOCK,
121
+ ALL_PYTHON_EXTENSIONS,
122
+ ALL_SELF_AND_CLS_PARAMETER_NAMES,
123
+ ALL_TEST_PATH_PATTERNS,
124
+ TYPE_CHECKING_BLOCK_PATTERN,
125
+ ALL_UNION_TYPING_NAMES,
126
+ UPPER_SNAKE_CONSTANT_PATTERN,
127
+ ALL_WORKFLOW_REGISTRY_PATTERNS,
128
+ )
99
129
 
100
130
 
101
131
  def get_file_extension(file_path: str) -> str:
@@ -109,7 +139,7 @@ def get_file_extension(file_path: str) -> str:
109
139
  def is_hook_infrastructure(file_path: str) -> bool:
110
140
  """Check if file is a Claude Code hook (standalone infrastructure, not project code)."""
111
141
  path_lower = file_path.lower().replace("\\", "/")
112
- return any(pattern.replace("\\", "/") in path_lower for pattern in HOOK_INFRASTRUCTURE_PATTERNS)
142
+ return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_HOOK_INFRASTRUCTURE_PATTERNS)
113
143
 
114
144
 
115
145
  def is_test_file(file_path: str) -> bool:
@@ -118,7 +148,7 @@ def is_test_file(file_path: str) -> bool:
118
148
  basename_lower = path_lower.replace("\\", "/").rsplit("/", 1)[-1]
119
149
  if basename_lower == "conftest.py":
120
150
  return True
121
- return any(pattern in path_lower for pattern in TEST_PATH_PATTERNS)
151
+ return any(pattern in path_lower for pattern in ALL_TEST_PATH_PATTERNS)
122
152
 
123
153
 
124
154
  def is_workflow_registry_file(file_path: str) -> bool:
@@ -129,7 +159,7 @@ def is_workflow_registry_file(file_path: str) -> bool:
129
159
  These are module-level singletons, not misplaced literal constants.
130
160
  """
131
161
  path_lower = file_path.lower().replace("\\", "/")
132
- return any(pattern.replace("\\", "/") in path_lower for pattern in WORKFLOW_REGISTRY_PATTERNS)
162
+ return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_WORKFLOW_REGISTRY_PATTERNS)
133
163
 
134
164
 
135
165
  def is_spec_file(file_path: str) -> bool:
@@ -163,23 +193,26 @@ def check_comments_python(content: str) -> list[str]:
163
193
  if stripped.startswith("# pragma:"):
164
194
  continue
165
195
 
196
+ if stripped.startswith(("# TODO", "# FIXME", "# HACK", "# XXX")):
197
+ continue
198
+
166
199
  comment_index = line.find("#")
167
200
  if comment_index != -1:
168
201
  before_comment = line[:comment_index]
169
202
  if not before_comment.strip().startswith(("'", '"')):
170
- in_string = False
203
+ is_in_string = False
171
204
  quote_char = None
172
- for i, char in enumerate(before_comment):
173
- if char in ("'", '"') and (i == 0 or before_comment[i - 1] != "\\"):
174
- if not in_string:
175
- in_string = True
176
- quote_char = char
177
- elif char == quote_char:
178
- in_string = False
179
-
180
- if not in_string:
205
+ for i, each_char in enumerate(before_comment):
206
+ if each_char in ("'", '"') and (i == 0 or before_comment[i - 1] != "\\"):
207
+ if not is_in_string:
208
+ is_in_string = True
209
+ quote_char = each_char
210
+ elif each_char == quote_char:
211
+ is_in_string = False
212
+
213
+ if not is_in_string:
181
214
  comment_text = line[comment_index + 1 :].strip()
182
- if comment_text and not comment_text.startswith(("type:", "noqa", "pylint:", "pragma:")):
215
+ if comment_text and not comment_text.startswith(("type:", "noqa", "pylint:", "pragma:", "TODO", "FIXME", "HACK", "XXX")):
183
216
  issues.append(f"Line {line_number}: Comment found - refactor to self-documenting code")
184
217
 
185
218
  if len(issues) >= 3:
@@ -192,28 +225,28 @@ def check_comments_javascript(content: str) -> list[str]:
192
225
  """Check for comments in JavaScript/TypeScript code."""
193
226
  issues = []
194
227
  lines = content.split("\n")
195
- in_multiline_comment = False
228
+ is_in_multiline_comment = False
196
229
 
197
- for line_number, line in enumerate(lines, 1):
198
- stripped = line.strip()
230
+ for each_line_number, each_line in enumerate(lines, 1):
231
+ stripped = each_line.strip()
199
232
 
200
233
  if not stripped:
201
234
  continue
202
235
 
203
- if in_multiline_comment:
236
+ if is_in_multiline_comment:
204
237
  if "*/" in stripped:
205
- in_multiline_comment = False
238
+ is_in_multiline_comment = False
206
239
  continue
207
240
 
208
241
  if stripped.startswith("/*"):
209
- in_multiline_comment = "*/" not in stripped
242
+ is_in_multiline_comment = "*/" not in stripped
210
243
  if not stripped.startswith("/**"):
211
- issues.append(f"Line {line_number}: Block comment found - refactor to self-documenting code")
244
+ issues.append(f"Line {each_line_number}: Block comment found - refactor to self-documenting code")
212
245
  continue
213
246
 
214
247
  if stripped.startswith("//"):
215
- if not stripped.startswith(("// @ts-", "// eslint-", "// prettier-", "/// ")):
216
- issues.append(f"Line {line_number}: Comment found - refactor to self-documenting code")
248
+ if not stripped.startswith(("// @ts-", "// eslint-", "// prettier-", "/// ", "// TODO", "// FIXME", "// HACK", "// XXX")):
249
+ issues.append(f"Line {each_line_number}: Comment found - refactor to self-documenting code")
217
250
 
218
251
  if len(issues) >= 3:
219
252
  break
@@ -237,55 +270,58 @@ def extract_comment_texts(content: str, file_path: str) -> tuple[set[str], set[s
237
270
 
238
271
  lines = content.split("\n")
239
272
 
240
- if extension in PYTHON_EXTENSIONS:
273
+ if extension in ALL_PYTHON_EXTENSIONS:
241
274
  for line in lines:
242
275
  stripped = line.strip()
243
276
  if not stripped:
244
277
  continue
245
278
  if stripped.startswith("#"):
246
- if stripped.startswith(("#!", "# type:", "# noqa", "# pylint:", "# pragma:")):
279
+ if stripped.startswith(("#!", "# type:", "# noqa", "# pylint:", "# pragma:", "# TODO", "# FIXME", "# HACK", "# XXX")):
247
280
  continue
248
281
  standalone_comments.add(stripped)
249
282
  elif "#" in line:
250
283
  comment_index = line.find("#")
251
284
  before_comment = line[:comment_index]
252
285
  if not before_comment.strip().startswith(("'", '"')):
253
- in_string = False
286
+ is_in_string = False
254
287
  quote_char = None
255
288
  for i, char in enumerate(before_comment):
256
289
  if char in ("'", '"') and (i == 0 or before_comment[i - 1] != "\\"):
257
- if not in_string:
258
- in_string = True
290
+ if not is_in_string:
291
+ is_in_string = True
259
292
  quote_char = char
260
293
  elif char == quote_char:
261
- in_string = False
262
- if not in_string:
294
+ is_in_string = False
295
+ if not is_in_string:
263
296
  comment_text = line[comment_index + 1 :].strip()
264
- if comment_text and not comment_text.startswith(("type:", "noqa", "pylint:", "pragma:")):
297
+ if comment_text and not comment_text.startswith(("type:", "noqa", "pylint:", "pragma:", "TODO", "FIXME", "HACK", "XXX")):
265
298
  inline_comments.add(line[comment_index:].strip())
266
299
 
267
- elif extension in JAVASCRIPT_EXTENSIONS:
268
- in_multiline = False
300
+ elif extension in ALL_JAVASCRIPT_EXTENSIONS:
301
+ is_in_multiline = False
269
302
  for line in lines:
270
303
  stripped = line.strip()
271
304
  if not stripped:
272
305
  continue
273
- if in_multiline:
306
+ if is_in_multiline:
274
307
  if "*/" in stripped:
275
- in_multiline = False
308
+ is_in_multiline = False
276
309
  continue
277
310
  if stripped.startswith("/*"):
278
- in_multiline = "*/" not in stripped
311
+ is_in_multiline = "*/" not in stripped
279
312
  if not stripped.startswith("/**"):
280
313
  standalone_comments.add(stripped)
281
314
  continue
282
315
  if stripped.startswith("//"):
283
- if not stripped.startswith(("// @ts-", "// eslint-", "// prettier-", "/// ")):
316
+ if not stripped.startswith(("// @ts-", "// eslint-", "// prettier-", "/// ", "// TODO", "// FIXME", "// HACK", "// XXX")):
284
317
  standalone_comments.add(stripped)
285
318
  elif "//" in line:
286
319
  before_slash = line[:line.index("//")]
287
320
  if before_slash.strip():
288
- inline_comments.add(stripped[stripped.index("//"):])
321
+ comment_start = stripped.index("//")
322
+ comment_text = stripped[comment_start + 2 :].strip()
323
+ if not comment_text.startswith(("TODO", "FIXME", "HACK", "XXX")):
324
+ inline_comments.add(stripped[comment_start:])
289
325
 
290
326
  return inline_comments, standalone_comments
291
327
 
@@ -349,7 +385,7 @@ def check_imports_at_top(content: str) -> list[str]:
349
385
  """
350
386
  issues: list[str] = []
351
387
  lines = content.split("\n")
352
- inside_function = False
388
+ is_inside_function = False
353
389
  function_indent = 0
354
390
  type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
355
391
 
@@ -372,29 +408,22 @@ def check_imports_at_top(content: str) -> list[str]:
372
408
 
373
409
  function_match = re.match(r"^(\s*)(async\s+)?def\s+\w+", each_line)
374
410
  if function_match:
375
- inside_function = True
411
+ is_inside_function = True
376
412
  function_indent = len(function_match.group(1)) if function_match.group(1) else 0
377
413
  continue
378
414
 
379
- if inside_function:
415
+ if is_inside_function:
380
416
  if current_indent <= function_indent and stripped and not stripped.startswith(("#", "@", ")")):
381
- inside_function = False
417
+ is_inside_function = False
382
418
 
383
419
  is_inside_type_checking_block = type_checking_block_indent != NOT_INSIDE_TYPE_CHECKING_BLOCK
384
- if inside_function and not is_inside_type_checking_block:
385
- if stripped.startswith(IMPORT_STATEMENT_PREFIXES):
420
+ if is_inside_function and not is_inside_type_checking_block:
421
+ if stripped.startswith(ALL_IMPORT_STATEMENT_PREFIXES):
386
422
  issues.append(f"Line {line_number}: Import inside function - move to top of file")
387
423
 
388
424
  return issues
389
425
 
390
426
 
391
- LOGGING_FSTRING_PATTERN = re.compile(
392
- r'\b(?:log_(?:debug|info|warning|error|critical|exception)'
393
- r'|(?:logger|logging|log)\.(?:debug|info|warning|error|critical|exception))'
394
- r'\s*\(\s*(?:[rR][fF]|[fF][rR]?)["\']'
395
- )
396
-
397
-
398
427
  def check_logging_fstrings(content: str) -> list[str]:
399
428
  """Check for f-strings in logging calls."""
400
429
  issues = []
@@ -482,7 +511,7 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
482
511
 
483
512
  issues = []
484
513
  lines = content.split("\n")
485
- inside_function = False
514
+ is_inside_function = False
486
515
 
487
516
  number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
488
517
  allowed_numbers = {"0", "1", "-1", "0.0", "1.0"}
@@ -494,14 +523,14 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
494
523
  continue
495
524
 
496
525
  if re.match(r"^(async\s+)?def\s+\w+", stripped):
497
- inside_function = True
526
+ is_inside_function = True
498
527
  continue
499
528
 
500
529
  if re.match(r"^class\s+\w+", stripped):
501
- inside_function = False
530
+ is_inside_function = False
502
531
  continue
503
532
 
504
- if inside_function:
533
+ if is_inside_function:
505
534
  if "=" in stripped and stripped.split("=")[0].strip().isupper():
506
535
  continue
507
536
 
@@ -693,7 +722,7 @@ def _find_any_annotation_lines(source: str) -> list[int]:
693
722
 
694
723
  offending_line_numbers: list[int] = []
695
724
  already_reported_lines: set[int] = set()
696
- for each_node in ast.walk(parsed_tree):
725
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
697
726
  if isinstance(each_node, ast.AnnAssign) and _annotation_uses_any(each_node.annotation):
698
727
  if each_node.lineno not in already_reported_lines:
699
728
  offending_line_numbers.append(each_node.lineno)
@@ -742,20 +771,138 @@ def _find_unjustified_type_ignore_lines(source: str) -> list[int]:
742
771
  return offending_line_numbers
743
772
 
744
773
 
774
+ def _find_typing_any_imports(source: str) -> list[int]:
775
+ """Return line numbers of `from typing import ... Any ...` statements."""
776
+ try:
777
+ parsed_tree = ast.parse(source)
778
+ except SyntaxError:
779
+ return []
780
+
781
+ offending_line_numbers: list[int] = []
782
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
783
+ if not isinstance(each_node, ast.ImportFrom):
784
+ continue
785
+ if each_node.module != "typing":
786
+ continue
787
+ for each_alias in each_node.names:
788
+ if each_alias.name == "Any":
789
+ offending_line_numbers.append(each_node.lineno)
790
+ break
791
+ return offending_line_numbers
792
+
793
+
794
+ def _find_typing_wildcard_imports(source: str) -> list[int]:
795
+ """Return line numbers of `from typing import *` statements."""
796
+ try:
797
+ parsed_tree = ast.parse(source)
798
+ except SyntaxError:
799
+ return []
800
+
801
+ offending_line_numbers: list[int] = []
802
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
803
+ if not isinstance(each_node, ast.ImportFrom):
804
+ continue
805
+ if each_node.module != "typing":
806
+ continue
807
+ for each_alias in each_node.names:
808
+ if each_alias.name == "*":
809
+ offending_line_numbers.append(each_node.lineno)
810
+ break
811
+ return offending_line_numbers
812
+
813
+
814
+ def _collect_typing_cast_import_names(source: str) -> frozenset[str]:
815
+ """Return the set of names bound to typing.cast via `from typing import cast`."""
816
+ try:
817
+ parsed_tree = ast.parse(source)
818
+ except SyntaxError:
819
+ return frozenset()
820
+
821
+ cast_names: set[str] = set()
822
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
823
+ if not isinstance(each_node, ast.ImportFrom):
824
+ continue
825
+ if each_node.module != "typing":
826
+ continue
827
+ for each_alias in each_node.names:
828
+ if each_alias.name == "cast":
829
+ cast_names.add(each_alias.asname or each_alias.name)
830
+ return frozenset(cast_names)
831
+
832
+
833
+ def _is_typing_cast_call(call_node: ast.Call, all_cast_import_names: frozenset[str]) -> bool:
834
+ """Return True when a Call node represents a typing.cast() or known bare cast()."""
835
+ function_node = call_node.func
836
+ if isinstance(function_node, ast.Attribute) and function_node.attr == "cast":
837
+ if isinstance(function_node.value, ast.Name) and function_node.value.id == "typing":
838
+ return True
839
+ if isinstance(function_node, ast.Name) and function_node.id in all_cast_import_names:
840
+ return True
841
+ return False
842
+
843
+
844
+ def _find_cast_call_lines(source: str) -> list[int]:
845
+ """Return line numbers of cast(...) calls (typing.cast or bare cast)."""
846
+ try:
847
+ parsed_tree = ast.parse(source)
848
+ except SyntaxError:
849
+ return []
850
+
851
+ all_cast_import_names = _collect_typing_cast_import_names(source)
852
+
853
+ offending_line_numbers: list[int] = []
854
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
855
+ if isinstance(each_node, ast.Call) and _is_typing_cast_call(each_node, all_cast_import_names):
856
+ offending_line_numbers.append(each_node.lineno)
857
+ return offending_line_numbers
858
+
859
+
860
+ def _file_path_matches_any_exemption(file_path: str) -> bool:
861
+ filename = file_path.replace("\\", "/").rsplit("/", 1)[-1].lower()
862
+ return filename in {each_pattern.lower() for each_pattern in ALL_ANY_ALLOWED_PATTERNS}
863
+
864
+
745
865
  def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
746
- """Flag Any annotations and unjustified # type: ignore comments."""
747
- if is_test_file(file_path):
866
+ """Flag Any annotations, Any imports, cast() calls, and unjustified # type: ignore."""
867
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
748
868
  return []
749
869
 
750
870
  issues: list[str] = []
871
+ is_any_exempt = _file_path_matches_any_exemption(file_path)
872
+
873
+ if not is_any_exempt:
874
+ any_annotation_issues: list[str] = []
875
+ for each_any_line in _find_any_annotation_lines(content):
876
+ any_annotation_issues.append(f"Line {each_any_line}: Any annotation - replace with explicit type")
877
+ issues.extend(any_annotation_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
878
+
879
+ any_import_issues: list[str] = []
880
+ for each_import_line in _find_typing_any_imports(content):
881
+ any_import_issues.append(
882
+ f"Line {each_import_line}: 'from typing import Any' - remove the Any import and use explicit types"
883
+ )
884
+ issues.extend(any_import_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
885
+
886
+ wildcard_issues: list[str] = []
887
+ for each_wildcard_line in _find_typing_wildcard_imports(content):
888
+ wildcard_issues.append(
889
+ f"Line {each_wildcard_line}: 'from typing import *' wildcard import - import explicit names instead"
890
+ )
891
+ issues.extend(wildcard_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
751
892
 
752
- for each_any_line in _find_any_annotation_lines(content):
753
- issues.append(f"Line {each_any_line}: Any annotation - replace with explicit type")
893
+ cast_issues: list[str] = []
894
+ for each_cast_line in _find_cast_call_lines(content):
895
+ cast_issues.append(
896
+ f"Line {each_cast_line}: cast() call - escape hatch around the type system; use explicit types or runtime validation"
897
+ )
898
+ issues.extend(cast_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
754
899
 
900
+ type_ignore_issues: list[str] = []
755
901
  for each_ignore_line in _find_unjustified_type_ignore_lines(content):
756
- issues.append(
902
+ type_ignore_issues.append(
757
903
  f"Line {each_ignore_line}: Unjustified # type: ignore - add trailing '# reason' explaining why"
758
904
  )
905
+ issues.extend(type_ignore_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
759
906
 
760
907
  return issues
761
908
 
@@ -763,7 +910,7 @@ def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
763
910
  def is_migration_file(file_path: str) -> bool:
764
911
  """Check if file is a Django migration (must be self-contained)."""
765
912
  path_lower = file_path.lower().replace("\\", "/")
766
- return any(pattern.replace("\\", "/") in path_lower for pattern in MIGRATION_PATH_PATTERNS)
913
+ return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_MIGRATION_PATH_PATTERNS)
767
914
 
768
915
 
769
916
  def check_constants_outside_config(content: str, file_path: str) -> list[str]:
@@ -782,8 +929,8 @@ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
782
929
 
783
930
  issues = []
784
931
  lines = content.split("\n")
785
- inside_function = False
786
- inside_class = False
932
+ is_inside_function = False
933
+ is_inside_class = False
787
934
 
788
935
  constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
789
936
 
@@ -794,20 +941,20 @@ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
794
941
  continue
795
942
 
796
943
  if re.match(r"^(async\s+)?def\s+\w+", stripped):
797
- inside_function = True
944
+ is_inside_function = True
798
945
  continue
799
946
 
800
947
  if re.match(r"^class\s+\w+", stripped):
801
- inside_class = True
802
- inside_function = False
948
+ is_inside_class = True
949
+ is_inside_function = False
803
950
  continue
804
951
 
805
952
  indent = len(line) - len(line.lstrip())
806
953
  if indent == 0 and stripped and not stripped.startswith(("#", "@", ")")):
807
- inside_function = False
808
- inside_class = False
954
+ is_inside_function = False
955
+ is_inside_class = False
809
956
 
810
- if not inside_function and not inside_class:
957
+ if not is_inside_function and not is_inside_class:
811
958
  match = constant_pattern.match(stripped)
812
959
  if match:
813
960
  constant_name = match.group(1)
@@ -926,6 +1073,28 @@ def _without_parse_args_namespace_exemption(
926
1073
  return [each_name for each_name in all_banned_names if each_name.id != "args"]
927
1074
 
928
1075
 
1076
+ def _synthesize_alias_name_node(
1077
+ bound_identifier: str, alias_node: ast.alias
1078
+ ) -> ast.Name:
1079
+ synthetic_name = ast.Name(id=bound_identifier, ctx=ast.Store())
1080
+ synthetic_name.lineno = alias_node.lineno
1081
+ synthetic_name.col_offset = alias_node.col_offset
1082
+ return synthetic_name
1083
+
1084
+
1085
+ def _collect_banned_names_from_import(
1086
+ import_statement: ast.Import | ast.ImportFrom,
1087
+ ) -> list[ast.Name]:
1088
+ banned_alias_nodes: list[ast.Name] = []
1089
+ for each_alias in import_statement.names:
1090
+ bound_identifier = each_alias.asname or each_alias.name.split(".")[0]
1091
+ if bound_identifier in ALL_BANNED_IDENTIFIERS:
1092
+ banned_alias_nodes.append(
1093
+ _synthesize_alias_name_node(bound_identifier, each_alias)
1094
+ )
1095
+ return banned_alias_nodes
1096
+
1097
+
929
1098
  def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
930
1099
  """Return banned ast.Name nodes introduced by a single binding construct."""
931
1100
  if isinstance(node, ast.Assign):
@@ -947,6 +1116,8 @@ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
947
1116
  if isinstance(node, ast.NamedExpr):
948
1117
  banned_names = _collect_banned_names_from_target(node.target)
949
1118
  return _without_parse_args_namespace_exemption(banned_names, node.value)
1119
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
1120
+ return _collect_banned_names_from_import(node)
950
1121
  return []
951
1122
 
952
1123
 
@@ -978,6 +1149,795 @@ def check_banned_identifiers(content: str, file_path: str) -> list[str]:
978
1149
  return issues
979
1150
 
980
1151
 
1152
+
1153
+
1154
+ def _string_constant_value(node: ast.expr) -> str | None:
1155
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
1156
+ return node.value
1157
+ return None
1158
+
1159
+
1160
+ def _is_environ_attribute(node: ast.expr) -> bool:
1161
+ if isinstance(node, ast.Attribute) and node.attr == "environ":
1162
+ return isinstance(node.value, ast.Name) and node.value.id == "os"
1163
+ return False
1164
+
1165
+
1166
+ def _environ_get_call_argument_names(call_node: ast.Call) -> list[str]:
1167
+ function_node = call_node.func
1168
+ if not isinstance(function_node, ast.Attribute):
1169
+ return []
1170
+ if function_node.attr != "get":
1171
+ return []
1172
+ if not _is_environ_attribute(function_node.value):
1173
+ return []
1174
+ if not call_node.args:
1175
+ return []
1176
+ first_argument = _string_constant_value(call_node.args[0])
1177
+ return [first_argument] if first_argument is not None else []
1178
+
1179
+
1180
+ def _environ_subscript_key_names(subscript_node: ast.Subscript) -> list[str]:
1181
+ if not _is_environ_attribute(subscript_node.value):
1182
+ return []
1183
+ key = _string_constant_value(subscript_node.slice)
1184
+ return [key] if key is not None else []
1185
+
1186
+
1187
+ def _environ_membership_key_names(compare_node: ast.Compare) -> list[str]:
1188
+ if not compare_node.ops:
1189
+ return []
1190
+ if not isinstance(compare_node.ops[0], (ast.In, ast.NotIn)):
1191
+ return []
1192
+ if not compare_node.comparators:
1193
+ return []
1194
+ if not _is_environ_attribute(compare_node.comparators[0]):
1195
+ return []
1196
+ key = _string_constant_value(compare_node.left)
1197
+ return [key] if key is not None else []
1198
+
1199
+
1200
+ def _collect_test_env_variable_references(parsed_tree: ast.AST) -> list[tuple[int, str]]:
1201
+ references: list[tuple[int, str]] = []
1202
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1203
+ candidate_names: list[str] = []
1204
+ if isinstance(each_node, ast.Call):
1205
+ candidate_names = _environ_get_call_argument_names(each_node)
1206
+ elif isinstance(each_node, ast.Subscript):
1207
+ candidate_names = _environ_subscript_key_names(each_node)
1208
+ elif isinstance(each_node, ast.Compare):
1209
+ candidate_names = _environ_membership_key_names(each_node)
1210
+ for each_candidate_name in candidate_names:
1211
+ if each_candidate_name in ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES:
1212
+ references.append((each_node.lineno, each_candidate_name))
1213
+ return references
1214
+
1215
+
1216
+ def check_test_branching_in_production(content: str, file_path: str) -> list[str]:
1217
+ """Flag production code that branches on TESTING-style env vars.
1218
+
1219
+ Production code reading TESTING / PYTEST_CURRENT_TEST creates two
1220
+ parallel implementations and hides bugs. Use dependency injection
1221
+ (override the dependency in tests) instead.
1222
+ """
1223
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1224
+ return []
1225
+
1226
+ try:
1227
+ parsed_tree = ast.parse(content)
1228
+ except SyntaxError:
1229
+ return []
1230
+
1231
+ references = _collect_test_env_variable_references(parsed_tree)
1232
+ references.sort(key=lambda each_reference: each_reference[0])
1233
+
1234
+ issues: list[str] = []
1235
+ already_reported_lines: set[int] = set()
1236
+ for each_line_number, each_variable_name in references:
1237
+ if each_line_number in already_reported_lines:
1238
+ continue
1239
+ already_reported_lines.add(each_line_number)
1240
+ issues.append(
1241
+ f"Line {each_line_number}: Production code reads test indicator '{each_variable_name}' — "
1242
+ "use dependency injection so production stays single-path"
1243
+ )
1244
+ if len(issues) >= MAX_TEST_BRANCHING_ISSUES:
1245
+ break
1246
+
1247
+ return issues
1248
+
1249
+
1250
+ def _bare_except_handler_label(handler_node: ast.ExceptHandler) -> str | None:
1251
+ """Return a label for handlers we flag, or None for safe handlers."""
1252
+ handler_type = handler_node.type
1253
+ if handler_type is None:
1254
+ return "bare except:"
1255
+ if isinstance(handler_type, ast.Name) and handler_type.id in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES:
1256
+ return f"except {handler_type.id}:"
1257
+ if (
1258
+ isinstance(handler_type, ast.Attribute)
1259
+ and handler_type.attr in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES
1260
+ ):
1261
+ return f"except {handler_type.attr}:"
1262
+ if isinstance(handler_type, ast.Tuple):
1263
+ banned_names: list[str] = []
1264
+ for each_element in handler_type.elts:
1265
+ if isinstance(each_element, ast.Name) and each_element.id in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES:
1266
+ banned_names.append(each_element.id)
1267
+ elif (
1268
+ isinstance(each_element, ast.Attribute)
1269
+ and each_element.attr in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES
1270
+ ):
1271
+ banned_names.append(each_element.attr)
1272
+ if banned_names:
1273
+ return f"except {', '.join(banned_names)} (in tuple):"
1274
+ return None
1275
+
1276
+
1277
+ def check_bare_except(content: str, file_path: str) -> list[str]:
1278
+ """Flag bare/over-broad exception handlers in production code.
1279
+
1280
+ ``except:`` and ``except BaseException:`` swallow KeyboardInterrupt and
1281
+ SystemExit; ``except Exception:`` hides bugs by catching nearly every
1282
+ error class. Production code should name the specific exception(s) it
1283
+ intends to catch
1284
+ (a tuple form like `except (ValueError, KeyError):` is fine).
1285
+ """
1286
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1287
+ return []
1288
+
1289
+ try:
1290
+ parsed_tree = ast.parse(content)
1291
+ except SyntaxError:
1292
+ return []
1293
+
1294
+ issues: list[str] = []
1295
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1296
+ if not isinstance(each_node, ast.ExceptHandler):
1297
+ continue
1298
+ handler_label = _bare_except_handler_label(each_node)
1299
+ if handler_label is None:
1300
+ continue
1301
+ issues.append(
1302
+ f"Line {each_node.lineno}: {handler_label} is over-broad — name the "
1303
+ "specific exception(s) you intend to handle"
1304
+ )
1305
+ if len(issues) >= MAX_BARE_EXCEPT_ISSUES:
1306
+ break
1307
+ return issues
1308
+
1309
+
1310
+ def _is_init_file(file_path: str) -> bool:
1311
+ return file_path.replace("\\", "/").rsplit("/", 1)[-1] == "__init__.py"
1312
+
1313
+
1314
+ def _statement_is_module_docstring(statement_node: ast.stmt) -> bool:
1315
+ return (
1316
+ isinstance(statement_node, ast.Expr)
1317
+ and isinstance(statement_node.value, ast.Constant)
1318
+ and isinstance(statement_node.value.value, str)
1319
+ )
1320
+
1321
+
1322
+ def _statement_is_dunder_all_assignment(statement_node: ast.stmt) -> bool:
1323
+ if isinstance(statement_node, ast.Assign):
1324
+ for each_target in statement_node.targets:
1325
+ if isinstance(each_target, ast.Name) and each_target.id == "__all__":
1326
+ return True
1327
+ return False
1328
+ if isinstance(statement_node, ast.AnnAssign):
1329
+ target = statement_node.target
1330
+ return isinstance(target, ast.Name) and target.id == "__all__"
1331
+ return False
1332
+
1333
+
1334
+ def _statement_is_import_or_reexport(statement_node: ast.stmt) -> bool:
1335
+ if isinstance(statement_node, (ast.Import, ast.ImportFrom)):
1336
+ return True
1337
+ if _statement_is_dunder_all_assignment(statement_node):
1338
+ return True
1339
+ return False
1340
+
1341
+
1342
+ def check_thin_wrapper_files(content: str, file_path: str) -> list[str]:
1343
+ """Flag non-`__init__.py` modules that are only imports + `__all__`.
1344
+
1345
+ A re-export-only wrapper outside `__init__.py` forces callers through an
1346
+ indirection layer with no payload of its own. Callers should import from
1347
+ the real module. `__init__.py` is the canonical re-export surface and is
1348
+ exempt; test files, hook infrastructure, and `config/` are also exempt.
1349
+ """
1350
+ if (
1351
+ is_test_file(file_path)
1352
+ or is_hook_infrastructure(file_path)
1353
+ or is_config_file(file_path)
1354
+ or _is_init_file(file_path)
1355
+ ):
1356
+ return []
1357
+
1358
+ try:
1359
+ parsed_tree = ast.parse(content)
1360
+ except SyntaxError:
1361
+ return []
1362
+
1363
+ body_statements = list(parsed_tree.body)
1364
+ if not body_statements:
1365
+ return []
1366
+
1367
+ statements_after_docstring = (
1368
+ body_statements[1:]
1369
+ if _statement_is_module_docstring(body_statements[0])
1370
+ else body_statements
1371
+ )
1372
+ if not statements_after_docstring:
1373
+ return []
1374
+
1375
+ for each_statement in statements_after_docstring:
1376
+ if not _statement_is_import_or_reexport(each_statement):
1377
+ return []
1378
+
1379
+ issues = [
1380
+ f"Line 1: {file_path}: thin wrapper file — module body is only imports (optionally with __all__); "
1381
+ "callers should import from the real module instead of going through this indirection"
1382
+ ]
1383
+ return issues[:MAX_THIN_WRAPPER_ISSUES]
1384
+
1385
+
1386
+ def _annotation_node_references_any(annotation_node: ast.expr | None) -> bool:
1387
+ if annotation_node is None:
1388
+ return False
1389
+ for each_descendant in ast.walk(annotation_node):
1390
+ if isinstance(each_descendant, ast.Name) and each_descendant.id == "Any":
1391
+ return True
1392
+ if isinstance(each_descendant, ast.Attribute) and each_descendant.attr == "Any":
1393
+ return True
1394
+ return False
1395
+
1396
+
1397
+ def _file_has_exempt_boundary_filename(file_path: str) -> bool:
1398
+ filename = file_path.replace("\\", "/").rsplit("/", 1)[-1].lower()
1399
+ return filename in {each_name.lower() for each_name in ALL_BOUNDARY_TYPE_EXEMPT_FILENAMES}
1400
+
1401
+
1402
+ def _signature_annotations(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[
1403
+ tuple[ast.expr, str, int]
1404
+ ]:
1405
+ collected_annotations: list[tuple[ast.expr, str, int]] = []
1406
+ function_name = function_node.name
1407
+ for each_argument in function_node.args.args:
1408
+ if each_argument.annotation is not None:
1409
+ collected_annotations.append(
1410
+ (each_argument.annotation, f"{function_name}({each_argument.arg})", each_argument.lineno)
1411
+ )
1412
+ for each_argument in function_node.args.posonlyargs:
1413
+ if each_argument.annotation is not None:
1414
+ collected_annotations.append(
1415
+ (each_argument.annotation, f"{function_name}({each_argument.arg})", each_argument.lineno)
1416
+ )
1417
+ for each_argument in function_node.args.kwonlyargs:
1418
+ if each_argument.annotation is not None:
1419
+ collected_annotations.append(
1420
+ (each_argument.annotation, f"{function_name}({each_argument.arg})", each_argument.lineno)
1421
+ )
1422
+ if function_node.args.vararg is not None and function_node.args.vararg.annotation is not None:
1423
+ collected_annotations.append(
1424
+ (function_node.args.vararg.annotation, f"{function_name}(*{function_node.args.vararg.arg})", function_node.args.vararg.lineno)
1425
+ )
1426
+ if function_node.args.kwarg is not None and function_node.args.kwarg.annotation is not None:
1427
+ collected_annotations.append(
1428
+ (function_node.args.kwarg.annotation, f"{function_name}(**{function_node.args.kwarg.arg})", function_node.args.kwarg.lineno)
1429
+ )
1430
+ if function_node.returns is not None:
1431
+ collected_annotations.append(
1432
+ (function_node.returns, f"{function_name} -> return", function_node.returns.lineno)
1433
+ )
1434
+ return collected_annotations
1435
+
1436
+
1437
+ def _class_attribute_annotations(class_node: ast.ClassDef) -> list[tuple[ast.expr, str, int]]:
1438
+ collected_annotations: list[tuple[ast.expr, str, int]] = []
1439
+ for each_statement in class_node.body:
1440
+ if isinstance(each_statement, ast.AnnAssign) and isinstance(each_statement.target, ast.Name):
1441
+ collected_annotations.append(
1442
+ (
1443
+ each_statement.annotation,
1444
+ f"{class_node.name}.{each_statement.target.id}",
1445
+ each_statement.lineno,
1446
+ )
1447
+ )
1448
+ return collected_annotations
1449
+
1450
+
1451
+ def check_boundary_types(content: str, file_path: str) -> list[str]:
1452
+ """Flag `Any` appearing in function signatures or class attribute annotations.
1453
+
1454
+ Module boundaries (function parameters, return types, class attributes)
1455
+ must name the concrete shape they accept and produce. Local variable
1456
+ annotations are private and exempt; `protocols.py` and `types.py` are
1457
+ interface-declaration files and exempt.
1458
+ """
1459
+ if (
1460
+ is_test_file(file_path)
1461
+ or is_hook_infrastructure(file_path)
1462
+ or _file_has_exempt_boundary_filename(file_path)
1463
+ ):
1464
+ return []
1465
+
1466
+ try:
1467
+ parsed_tree = ast.parse(content)
1468
+ except SyntaxError:
1469
+ return []
1470
+
1471
+ issues: list[str] = []
1472
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1473
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1474
+ for each_annotation, each_label, each_line_number in _signature_annotations(each_node):
1475
+ if _annotation_node_references_any(each_annotation):
1476
+ issues.append(
1477
+ f"Line {each_line_number}: {each_label} uses Any at module boundary — "
1478
+ "name the concrete shape callers receive/produce"
1479
+ )
1480
+ elif isinstance(each_node, ast.ClassDef):
1481
+ for each_annotation, each_label, each_line_number in _class_attribute_annotations(each_node):
1482
+ if _annotation_node_references_any(each_annotation):
1483
+ issues.append(
1484
+ f"Line {each_line_number}: {each_label} uses Any at class boundary — "
1485
+ "name the concrete shape this attribute holds"
1486
+ )
1487
+ if len(issues) >= MAX_BOUNDARY_TYPE_ISSUES:
1488
+ break
1489
+ return issues[:MAX_BOUNDARY_TYPE_ISSUES]
1490
+
1491
+
1492
+ def _function_is_private_or_dunder(function_name: str) -> bool:
1493
+ if function_name.startswith("__") and function_name.endswith("__"):
1494
+ return True
1495
+ return function_name.startswith("_")
1496
+
1497
+
1498
+ def _decorator_label(decorator_node: ast.expr) -> str:
1499
+ if isinstance(decorator_node, ast.Name):
1500
+ return decorator_node.id
1501
+ if isinstance(decorator_node, ast.Attribute):
1502
+ prefix = (
1503
+ decorator_node.value.id
1504
+ if isinstance(decorator_node.value, ast.Name)
1505
+ else ""
1506
+ )
1507
+ return f"{prefix}.{decorator_node.attr}" if prefix else decorator_node.attr
1508
+ if isinstance(decorator_node, ast.Call):
1509
+ return _decorator_label(decorator_node.func)
1510
+ return ""
1511
+
1512
+
1513
+ def _function_has_exempt_decorator(
1514
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1515
+ ) -> bool:
1516
+ for each_decorator in function_node.decorator_list:
1517
+ if _decorator_label(each_decorator) in ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES:
1518
+ return True
1519
+ return False
1520
+
1521
+
1522
+ def _function_body_line_count(
1523
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1524
+ ) -> int:
1525
+ if not function_node.body:
1526
+ return 0
1527
+ first_body_index = 0
1528
+ if (
1529
+ isinstance(function_node.body[0], ast.Expr)
1530
+ and isinstance(function_node.body[0].value, ast.Constant)
1531
+ and isinstance(function_node.body[0].value.value, str)
1532
+ ):
1533
+ if len(function_node.body) == 1:
1534
+ return 0
1535
+ first_body_index = 1
1536
+ last_statement = function_node.body[-1]
1537
+ end_line = getattr(last_statement, "end_lineno", last_statement.lineno)
1538
+ first_line = function_node.body[first_body_index].lineno
1539
+ return max(0, end_line - first_line + 1)
1540
+
1541
+
1542
+ def _function_documentable_parameter_count(
1543
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1544
+ ) -> int:
1545
+ documentable_count = 0
1546
+ for each_argument in function_node.args.args:
1547
+ if each_argument.arg in ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES:
1548
+ continue
1549
+ documentable_count += 1
1550
+ documentable_count += len(function_node.args.kwonlyargs)
1551
+ for each_argument in function_node.args.posonlyargs:
1552
+ if each_argument.arg in ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES:
1553
+ continue
1554
+ documentable_count += 1
1555
+ if function_node.args.vararg is not None:
1556
+ documentable_count += 1
1557
+ if function_node.args.kwarg is not None:
1558
+ documentable_count += 1
1559
+ return documentable_count
1560
+
1561
+
1562
+ def _annotation_is_explicit_none_return(annotation_node: ast.expr | None) -> bool:
1563
+ if annotation_node is None:
1564
+ return False
1565
+ if isinstance(annotation_node, ast.Constant) and annotation_node.value is None:
1566
+ return True
1567
+ return isinstance(annotation_node, ast.Name) and annotation_node.id == "None"
1568
+
1569
+
1570
+ def _annotation_is_noreturn(annotation_node: ast.expr | None) -> bool:
1571
+ if annotation_node is None:
1572
+ return False
1573
+ if isinstance(annotation_node, ast.Name) and annotation_node.id == "NoReturn":
1574
+ return True
1575
+ return isinstance(annotation_node, ast.Attribute) and annotation_node.attr == "NoReturn"
1576
+
1577
+
1578
+ def _walk_skipping_nested_functions(node: ast.AST) -> "Iterator[ast.AST]":
1579
+ for each_child in ast.iter_child_nodes(node):
1580
+ if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1581
+ continue
1582
+ yield each_child
1583
+ yield from _walk_skipping_nested_functions(each_child)
1584
+
1585
+
1586
+ def _is_type_checking_guard(if_node: ast.If) -> bool:
1587
+ test_node = if_node.test
1588
+ if isinstance(test_node, ast.Name) and test_node.id == TYPE_CHECKING_IDENTIFIER:
1589
+ return True
1590
+ return isinstance(test_node, ast.Attribute) and test_node.attr == TYPE_CHECKING_IDENTIFIER
1591
+
1592
+
1593
+ def _walk_skipping_type_checking_blocks(node: ast.AST) -> "Iterator[ast.AST]":
1594
+ for each_child in ast.iter_child_nodes(node):
1595
+ if isinstance(each_child, ast.If) and _is_type_checking_guard(each_child):
1596
+ continue
1597
+ yield each_child
1598
+ yield from _walk_skipping_type_checking_blocks(each_child)
1599
+
1600
+
1601
+ def _function_body_contains_raise(
1602
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1603
+ ) -> bool:
1604
+ return any(
1605
+ isinstance(each_descendant, ast.Raise)
1606
+ for each_descendant in _walk_skipping_nested_functions(function_node)
1607
+ )
1608
+
1609
+
1610
+ def _function_body_contains_yield(
1611
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1612
+ ) -> bool:
1613
+ return any(
1614
+ isinstance(each_descendant, (ast.Yield, ast.YieldFrom))
1615
+ for each_descendant in _walk_skipping_nested_functions(function_node)
1616
+ )
1617
+
1618
+
1619
+ def _function_docstring_text(
1620
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1621
+ ) -> str:
1622
+ docstring_value = ast.get_docstring(function_node)
1623
+ return docstring_value or ""
1624
+
1625
+
1626
+ def _missing_docstring_sections(
1627
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1628
+ ) -> list[str]:
1629
+ docstring_text = _function_docstring_text(function_node)
1630
+ documentable_parameter_count = _function_documentable_parameter_count(function_node)
1631
+ has_non_none_return = (
1632
+ function_node.returns is not None
1633
+ and not _annotation_is_explicit_none_return(function_node.returns)
1634
+ and not _annotation_is_noreturn(function_node.returns)
1635
+ )
1636
+ has_raise_statement = _function_body_contains_raise(function_node)
1637
+ has_yield_statement = _function_body_contains_yield(function_node)
1638
+ missing_sections: list[str] = []
1639
+ if documentable_parameter_count > 0 and "Args:" not in docstring_text:
1640
+ missing_sections.append("Args:")
1641
+ if has_non_none_return and not (
1642
+ "Returns:" in docstring_text or "Yields:" in docstring_text
1643
+ ):
1644
+ section_label = "Yields:" if has_yield_statement else "Returns:"
1645
+ missing_sections.append(section_label)
1646
+ if has_raise_statement and "Raises:" not in docstring_text:
1647
+ missing_sections.append("Raises:")
1648
+ return missing_sections
1649
+
1650
+
1651
+ def check_docstring_format(content: str, file_path: str) -> list[str]:
1652
+ """Flag public functions missing required Google-style docstring sections.
1653
+
1654
+ A public function whose signature has documentable parameters, returns
1655
+ a non-None value, or raises must have the matching `Args:` / `Returns:`
1656
+ (or `Yields:`) / `Raises:` sections so callers can read the contract
1657
+ without scanning the body.
1658
+ """
1659
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1660
+ return []
1661
+
1662
+ try:
1663
+ parsed_tree = ast.parse(content)
1664
+ except SyntaxError:
1665
+ return []
1666
+
1667
+ issues: list[str] = []
1668
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1669
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1670
+ continue
1671
+ if _function_is_private_or_dunder(each_node.name):
1672
+ continue
1673
+ if _function_has_exempt_decorator(each_node):
1674
+ continue
1675
+ if _function_body_line_count(each_node) <= DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT:
1676
+ continue
1677
+ missing_sections = _missing_docstring_sections(each_node)
1678
+ if not missing_sections:
1679
+ continue
1680
+ issues.append(
1681
+ f"Line {each_node.lineno}: {each_node.name}() docstring missing required "
1682
+ f"section(s): {', '.join(missing_sections)} — Google style required for public APIs"
1683
+ )
1684
+ if len(issues) >= MAX_DOCSTRING_FORMAT_ISSUES:
1685
+ break
1686
+ return issues[:MAX_DOCSTRING_FORMAT_ISSUES]
1687
+
1688
+
1689
+ _PASCAL_TO_SNAKE_WORD_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
1690
+
1691
+
1692
+ def _pascal_to_snake_case(pascal_name: str) -> str:
1693
+ return _PASCAL_TO_SNAKE_WORD_BOUNDARY.sub("_", pascal_name).lower()
1694
+
1695
+
1696
+ def _class_inherits_from_typed_dict(class_node: ast.ClassDef) -> bool:
1697
+ for each_base in class_node.bases:
1698
+ if isinstance(each_base, ast.Name) and each_base.id == "TypedDict":
1699
+ return True
1700
+ if isinstance(each_base, ast.Attribute) and each_base.attr == "TypedDict":
1701
+ return True
1702
+ return False
1703
+
1704
+
1705
+ def _collect_typed_dict_class_names(parsed_tree: ast.AST) -> list[tuple[str, int]]:
1706
+ typed_dict_entries: list[tuple[str, int]] = []
1707
+ for each_statement in parsed_tree.body:
1708
+ if isinstance(each_statement, ast.ClassDef) and _class_inherits_from_typed_dict(each_statement):
1709
+ typed_dict_entries.append((each_statement.name, each_statement.lineno))
1710
+ return typed_dict_entries
1711
+
1712
+
1713
+ def _collect_module_function_names(parsed_tree: ast.AST) -> set[str]:
1714
+ module_function_names: set[str] = set()
1715
+ for each_statement in parsed_tree.body:
1716
+ if isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
1717
+ module_function_names.add(each_statement.name)
1718
+ return module_function_names
1719
+
1720
+
1721
+ def check_typed_dict_encode_decode(content: str, file_path: str) -> list[str]:
1722
+ """Flag TypedDict declarations missing companion `_encode_*` / `_decode_*` functions."""
1723
+ if (
1724
+ is_test_file(file_path)
1725
+ or is_hook_infrastructure(file_path)
1726
+ or _is_init_file(file_path)
1727
+ ):
1728
+ return []
1729
+
1730
+ try:
1731
+ parsed_tree = ast.parse(content)
1732
+ except SyntaxError:
1733
+ return []
1734
+
1735
+ typed_dict_entries = _collect_typed_dict_class_names(parsed_tree)
1736
+ if not typed_dict_entries:
1737
+ return []
1738
+
1739
+ module_function_names = _collect_module_function_names(parsed_tree)
1740
+
1741
+ issues: list[str] = []
1742
+ for each_typed_dict_name, each_typed_dict_line in typed_dict_entries:
1743
+ snake_name = _pascal_to_snake_case(each_typed_dict_name)
1744
+ encoder_function_name = f"_encode_{snake_name}"
1745
+ decoder_function_name = f"_decode_{snake_name}"
1746
+ is_encoder_present = encoder_function_name in module_function_names
1747
+ is_decoder_present = decoder_function_name in module_function_names
1748
+ if is_encoder_present and is_decoder_present:
1749
+ continue
1750
+ missing_companions: list[str] = []
1751
+ if not is_encoder_present:
1752
+ missing_companions.append(encoder_function_name)
1753
+ if not is_decoder_present:
1754
+ missing_companions.append(decoder_function_name)
1755
+ issues.append(
1756
+ f"Line {each_typed_dict_line}: TypedDict '{each_typed_dict_name}' missing companion "
1757
+ f"{' and '.join(missing_companions)} — add explicit encode/decode functions"
1758
+ )
1759
+ if len(issues) >= MAX_TYPED_DICT_PAIR_ISSUES:
1760
+ break
1761
+
1762
+ return issues
1763
+
1764
+
1765
+ def _function_decorator_is_abstractmethod(decorator_node: ast.expr) -> bool:
1766
+ if isinstance(decorator_node, ast.Name) and decorator_node.id == "abstractmethod":
1767
+ return True
1768
+ if isinstance(decorator_node, ast.Attribute) and decorator_node.attr == "abstractmethod":
1769
+ return True
1770
+ return False
1771
+
1772
+
1773
+ def _function_is_abstract(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
1774
+ return any(
1775
+ _function_decorator_is_abstractmethod(each_decorator)
1776
+ for each_decorator in function_node.decorator_list
1777
+ )
1778
+
1779
+
1780
+ def _function_is_overload(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
1781
+ for each_decorator in function_node.decorator_list:
1782
+ if isinstance(each_decorator, ast.Name) and each_decorator.id == "overload":
1783
+ return True
1784
+ if isinstance(each_decorator, ast.Attribute) and each_decorator.attr == "overload":
1785
+ return True
1786
+ return False
1787
+
1788
+
1789
+ def _class_is_protocol(class_node: ast.ClassDef) -> bool:
1790
+ for each_base in class_node.bases:
1791
+ if isinstance(each_base, ast.Name) and each_base.id == "Protocol":
1792
+ return True
1793
+ if isinstance(each_base, ast.Attribute) and each_base.attr == "Protocol":
1794
+ return True
1795
+ return False
1796
+
1797
+
1798
+ def _class_inherits_from_protocol_or_abc(class_node: ast.ClassDef) -> bool:
1799
+ for each_base in class_node.bases:
1800
+ if isinstance(each_base, ast.Name) and each_base.id in {"Protocol", "ABC"}:
1801
+ return True
1802
+ if isinstance(each_base, ast.Attribute) and each_base.attr in {"Protocol", "ABC"}:
1803
+ return True
1804
+ return False
1805
+
1806
+
1807
+
1808
+
1809
+
1810
+
1811
+ def _statement_is_pass(statement_node: ast.stmt) -> bool:
1812
+ return isinstance(statement_node, ast.Pass)
1813
+
1814
+
1815
+ def _statement_is_ellipsis(statement_node: ast.stmt) -> bool:
1816
+ return (
1817
+ isinstance(statement_node, ast.Expr)
1818
+ and isinstance(statement_node.value, ast.Constant)
1819
+ and statement_node.value.value is Ellipsis
1820
+ )
1821
+
1822
+
1823
+ def _statement_is_raise_not_implemented(statement_node: ast.stmt) -> bool:
1824
+ if not isinstance(statement_node, ast.Raise):
1825
+ return False
1826
+ raised_expression = statement_node.exc
1827
+ if raised_expression is None:
1828
+ return False
1829
+ if isinstance(raised_expression, ast.Name) and raised_expression.id == "NotImplementedError":
1830
+ return True
1831
+ if (
1832
+ isinstance(raised_expression, ast.Call)
1833
+ and isinstance(raised_expression.func, ast.Name)
1834
+ and raised_expression.func.id == "NotImplementedError"
1835
+ ):
1836
+ return True
1837
+ return False
1838
+
1839
+
1840
+ def _function_body_is_stub(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
1841
+ body_statements = list(function_node.body)
1842
+ if body_statements and _statement_is_module_docstring(body_statements[0]):
1843
+ body_statements = body_statements[1:]
1844
+ if len(body_statements) != 1:
1845
+ return False
1846
+ sole_statement = body_statements[0]
1847
+ return (
1848
+ _statement_is_pass(sole_statement)
1849
+ or _statement_is_ellipsis(sole_statement)
1850
+ or _statement_is_raise_not_implemented(sole_statement)
1851
+ )
1852
+
1853
+
1854
+ def check_stub_implementations(content: str, file_path: str) -> list[str]:
1855
+ """Flag production functions whose body is only pass/.../raise NotImplementedError.
1856
+
1857
+ Stubs ship as placeholders that the rest of the system depends on but the
1858
+ function does not deliver. ABC/Protocol abstract methods are exempt — they
1859
+ are placeholders BY contract, not by oversight.
1860
+ """
1861
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1862
+ return []
1863
+
1864
+ try:
1865
+ parsed_tree = ast.parse(content)
1866
+ except SyntaxError:
1867
+ return []
1868
+
1869
+ abstract_class_function_ids: set[int] = set()
1870
+ for each_node in ast.walk(parsed_tree):
1871
+ if isinstance(each_node, ast.ClassDef) and _class_inherits_from_protocol_or_abc(each_node):
1872
+ is_protocol = _class_is_protocol(each_node)
1873
+ for each_class_member in each_node.body:
1874
+ if not isinstance(each_class_member, (ast.FunctionDef, ast.AsyncFunctionDef)):
1875
+ continue
1876
+ if is_protocol or _function_is_abstract(each_class_member):
1877
+ abstract_class_function_ids.add(id(each_class_member))
1878
+
1879
+ stub_function_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
1880
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1881
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1882
+ continue
1883
+ if _function_is_abstract(each_node) or _function_is_overload(each_node):
1884
+ continue
1885
+ if id(each_node) in abstract_class_function_ids:
1886
+ continue
1887
+ if _function_body_is_stub(each_node):
1888
+ stub_function_nodes.append(each_node)
1889
+
1890
+ stub_function_nodes.sort(key=lambda each_function: each_function.lineno)
1891
+
1892
+ issues: list[str] = []
1893
+ for each_function in stub_function_nodes:
1894
+ issues.append(
1895
+ f"Line {each_function.lineno}: Function '{each_function.name}' is a stub "
1896
+ "(pass/.../raise NotImplementedError) — implement or remove"
1897
+ )
1898
+ if len(issues) >= MAX_STUB_IMPLEMENTATION_ISSUES:
1899
+ break
1900
+
1901
+ return issues
1902
+
1903
+
1904
+ def check_banned_prefixes(content: str, file_path: str) -> list[str]:
1905
+ """Flag function and method names using generic banned prefixes.
1906
+
1907
+ Per CODE_RULES.md / AGENTS.md Naming, function names use specific verbs.
1908
+ Generic prefixes ``handle_``, ``process_``, ``manage_``, ``do_`` are
1909
+ placeholders that hide the actual responsibility and are flagged so the
1910
+ author renames the function to a specific verb.
1911
+ """
1912
+ if is_test_file(file_path) or is_hook_infrastructure(file_path) or is_config_file(file_path):
1913
+ return []
1914
+
1915
+ try:
1916
+ parsed_tree = ast.parse(content)
1917
+ except SyntaxError:
1918
+ return []
1919
+
1920
+ flagged_function_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
1921
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1922
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1923
+ continue
1924
+ if any(each_node.name.startswith(each_prefix) for each_prefix in ALL_BANNED_PREFIX_NAMES):
1925
+ flagged_function_nodes.append(each_node)
1926
+
1927
+ flagged_function_nodes.sort(key=lambda each_function: each_function.lineno)
1928
+
1929
+ issues: list[str] = []
1930
+ for each_function in flagged_function_nodes:
1931
+ issues.append(
1932
+ f"Line {each_function.lineno}: Function '{each_function.name}' uses banned prefix - "
1933
+ "rename to a specific verb (see CODE_RULES Naming section)"
1934
+ )
1935
+ if len(issues) >= MAX_BANNED_PREFIX_ISSUES:
1936
+ break
1937
+
1938
+ return issues
1939
+
1940
+
981
1941
  def _is_bool_constant(node: ast.AST) -> bool:
982
1942
  return isinstance(node, ast.Constant) and isinstance(node.value, bool)
983
1943
 
@@ -1099,7 +2059,7 @@ def check_boolean_naming(content: str, file_path: str) -> list[str]:
1099
2059
  continue
1100
2060
  if is_in_upper_snake_scope and UPPER_SNAKE_CONSTANT_PATTERN.match(name):
1101
2061
  continue
1102
- if name.startswith(BOOLEAN_NAME_PREFIXES):
2062
+ if name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
1103
2063
  continue
1104
2064
  issues.append(
1105
2065
  f"Line {line_number}: Boolean {name} - prefix with is_/has_/should_/can_"
@@ -1385,7 +2345,7 @@ def check_file_global_constants_use_count(content: str, file_path: str) -> list[
1385
2345
  return []
1386
2346
  if is_config_file(file_path):
1387
2347
  return []
1388
- if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
2348
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
1389
2349
  return []
1390
2350
  if file_path.replace("\\", "/").endswith("hooks/blocking/code_rules_enforcer.py"):
1391
2351
  return []
@@ -1445,9 +2405,9 @@ def _collect_optional_param_defaults(
1445
2405
  _NON_LITERAL_DEFAULT_SENTINEL = object()
1446
2406
 
1447
2407
 
1448
- def _is_non_literal_default(value: object) -> bool:
2408
+ def _is_non_literal_default(candidate_default: object) -> bool:
1449
2409
  """Return True when a value is the sentinel for a non-literal default."""
1450
- return value is _NON_LITERAL_DEFAULT_SENTINEL
2410
+ return candidate_default is _NON_LITERAL_DEFAULT_SENTINEL
1451
2411
 
1452
2412
 
1453
2413
  def _ast_constant_value(node: ast.expr) -> object:
@@ -1536,12 +2496,6 @@ def _function_name_from_call(call_node: ast.Call) -> str | None:
1536
2496
  return None
1537
2497
 
1538
2498
 
1539
- BUILTIN_DICT_METHOD_NAMES: frozenset[str] = frozenset({
1540
- "get", "items", "keys", "values", "update", "pop",
1541
- "setdefault", "copy", "clear",
1542
- })
1543
-
1544
-
1545
2499
  def _collect_mock_dict_keys(assign_value: ast.expr) -> set[str] | None:
1546
2500
  """Return the string key set for a dict literal, or None if not a dict literal."""
1547
2501
  if not isinstance(assign_value, ast.Dict):
@@ -1695,7 +2649,7 @@ def _collect_mock_field_accesses_in_scope(
1695
2649
  if isinstance(each_node, ast.Attribute):
1696
2650
  if isinstance(each_node.value, ast.Name) and each_node.value.id == mock_name:
1697
2651
  if isinstance(each_node.ctx, ast.Load):
1698
- if each_node.attr in BUILTIN_DICT_METHOD_NAMES:
2652
+ if each_node.attr in ALL_BUILTIN_DICT_METHOD_NAMES:
1699
2653
  continue
1700
2654
  accesses.append((each_node.attr, each_node.lineno))
1701
2655
  elif isinstance(each_node, ast.Subscript):
@@ -1966,16 +2920,15 @@ def check_unused_optional_parameters(content: str, file_path: str) -> list[str]:
1966
2920
  return issues
1967
2921
 
1968
2922
 
1969
- UNION_TYPING_NAMES: frozenset[str] = frozenset({"Optional", "Union"})
1970
2923
 
1971
2924
 
1972
2925
  def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
1973
2926
  if annotation_node is None:
1974
2927
  return False
1975
2928
  if isinstance(annotation_node, ast.Name):
1976
- return annotation_node.id in COLLECTION_TYPE_NAMES
2929
+ return annotation_node.id in ALL_COLLECTION_TYPE_NAMES
1977
2930
  if isinstance(annotation_node, ast.Attribute):
1978
- return annotation_node.attr in COLLECTION_TYPE_NAMES
2931
+ return annotation_node.attr in ALL_COLLECTION_TYPE_NAMES
1979
2932
  if isinstance(annotation_node, ast.BinOp) and isinstance(annotation_node.op, ast.BitOr):
1980
2933
  return (
1981
2934
  _annotation_names_collection(annotation_node.left)
@@ -1984,8 +2937,8 @@ def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
1984
2937
  if isinstance(annotation_node, ast.Subscript):
1985
2938
  outer_value = annotation_node.value
1986
2939
  is_optional_or_union_subscript = (
1987
- (isinstance(outer_value, ast.Name) and outer_value.id in UNION_TYPING_NAMES)
1988
- or (isinstance(outer_value, ast.Attribute) and outer_value.attr in UNION_TYPING_NAMES)
2940
+ (isinstance(outer_value, ast.Name) and outer_value.id in ALL_UNION_TYPING_NAMES)
2941
+ or (isinstance(outer_value, ast.Attribute) and outer_value.attr in ALL_UNION_TYPING_NAMES)
1989
2942
  )
1990
2943
  if is_optional_or_union_subscript:
1991
2944
  slice_node = annotation_node.slice
@@ -1995,6 +2948,12 @@ def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
1995
2948
  for each_element in slice_node.elts
1996
2949
  )
1997
2950
  return _annotation_names_collection(slice_node)
2951
+ is_subscript_only_collection_type = (
2952
+ (isinstance(outer_value, ast.Name) and outer_value.id in ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES)
2953
+ or (isinstance(outer_value, ast.Attribute) and outer_value.attr in ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES)
2954
+ )
2955
+ if is_subscript_only_collection_type:
2956
+ return True
1998
2957
  return _annotation_names_collection(outer_value)
1999
2958
  return False
2000
2959
 
@@ -2580,7 +3539,30 @@ def _collect_load_names_outside_import_ranges(
2580
3539
  return referenced_names
2581
3540
 
2582
3541
 
2583
- def check_unused_module_level_imports(content: str, file_path: str) -> list[str]:
3542
+ def _module_declares_dunder_all(tree: ast.Module) -> bool:
3543
+ """Return True when the module body assigns or annotates ``__all__``."""
3544
+ return any(
3545
+ (
3546
+ isinstance(each_node, ast.Assign)
3547
+ and any(
3548
+ isinstance(each_target, ast.Name) and each_target.id == "__all__"
3549
+ for each_target in each_node.targets
3550
+ )
3551
+ )
3552
+ or (
3553
+ isinstance(each_node, ast.AnnAssign)
3554
+ and isinstance(each_node.target, ast.Name)
3555
+ and each_node.target.id == "__all__"
3556
+ )
3557
+ for each_node in tree.body
3558
+ )
3559
+
3560
+
3561
+ def check_unused_module_level_imports(
3562
+ content: str,
3563
+ file_path: str,
3564
+ full_file_content: str | None = None,
3565
+ ) -> list[str]:
2584
3566
  """Flag module-level imports that are never referenced in the rest of the file.
2585
3567
 
2586
3568
  References are detected from AST ``Name`` / ``Attribute`` loads outside import
@@ -2589,42 +3571,39 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2589
3571
  whose module body includes ``if TYPE_CHECKING:`` (or
2590
3572
  ``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
2591
3573
  ``# noqa`` or an explicit ``F401`` code in the noqa list only.
3574
+
3575
+ When ``full_file_content`` is provided, ``content`` is treated as an Edit
3576
+ fragment containing the imports being added or replaced, while the
3577
+ ``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
3578
+ against ``full_file_content`` (the post-edit file as it will look once the
3579
+ Edit applies). This prevents false-positive flags on imports added in the
3580
+ same Edit as their consumers.
2592
3581
  """
2593
3582
  if is_test_file(file_path):
2594
3583
  return []
2595
3584
  if is_workflow_registry_file(file_path) or is_migration_file(file_path):
2596
3585
  return []
2597
3586
  try:
2598
- tree = ast.parse(content)
3587
+ fragment_tree = ast.parse(content)
2599
3588
  except SyntaxError:
2600
3589
  return []
2601
- file_declares_dunder_all = any(
2602
- (
2603
- isinstance(each_node, ast.Assign)
2604
- and any(
2605
- isinstance(each_target, ast.Name) and each_target.id == "__all__"
2606
- for each_target in each_node.targets
2607
- )
2608
- )
2609
- or (
2610
- isinstance(each_node, ast.AnnAssign)
2611
- and isinstance(each_node.target, ast.Name)
2612
- and each_node.target.id == "__all__"
2613
- )
2614
- for each_node in tree.body
2615
- )
2616
- if file_declares_dunder_all:
3590
+ reference_source = full_file_content if full_file_content is not None else content
3591
+ try:
3592
+ reference_tree = ast.parse(reference_source)
3593
+ except SyntaxError:
2617
3594
  return []
2618
- if _module_body_declares_type_checking_gate(tree):
3595
+ if _module_declares_dunder_all(reference_tree):
2619
3596
  return []
2620
- content_lines = content.splitlines()
2621
- import_line_ranges = _import_statement_line_ranges(tree)
3597
+ if _module_body_declares_type_checking_gate(reference_tree):
3598
+ return []
3599
+ fragment_lines = content.splitlines()
3600
+ reference_import_ranges = _import_statement_line_ranges(reference_tree)
2622
3601
  referenced_names = _collect_load_names_outside_import_ranges(
2623
- tree,
2624
- import_line_ranges,
3602
+ reference_tree,
3603
+ reference_import_ranges,
2625
3604
  )
2626
3605
  import_bindings: list[tuple[str, int, int | None]] = []
2627
- for each_node in tree.body:
3606
+ for each_node in fragment_tree.body:
2628
3607
  if isinstance(each_node, (ast.Import, ast.ImportFrom)):
2629
3608
  if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
2630
3609
  continue
@@ -2632,14 +3611,14 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2632
3611
  import_bindings.append(each_binding)
2633
3612
  issues: list[str] = []
2634
3613
  for each_name, each_line_number, each_from_keyword_line in import_bindings:
2635
- if 1 <= each_line_number <= len(content_lines):
2636
- if line_suppresses_unused_import_via_noqa(content_lines[each_line_number - 1]):
3614
+ if 1 <= each_line_number <= len(fragment_lines):
3615
+ if line_suppresses_unused_import_via_noqa(fragment_lines[each_line_number - 1]):
2637
3616
  continue
2638
3617
  if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
2639
- content_lines
3618
+ fragment_lines
2640
3619
  ):
2641
3620
  if line_suppresses_unused_import_via_noqa(
2642
- content_lines[each_from_keyword_line - 1]
3621
+ fragment_lines[each_from_keyword_line - 1]
2643
3622
  ):
2644
3623
  continue
2645
3624
  if each_name in referenced_names:
@@ -2655,7 +3634,7 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2655
3634
 
2656
3635
  def _is_cli_entry_point(file_path: str) -> bool:
2657
3636
  path_lower = file_path.lower().replace("\\", "/")
2658
- return any(marker.replace("\\", "/") in path_lower for marker in CLI_FILE_PATH_MARKERS)
3637
+ return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)
2659
3638
 
2660
3639
 
2661
3640
  def check_library_print(content: str, file_path: str) -> list[str]:
@@ -2663,7 +3642,7 @@ def check_library_print(content: str, file_path: str) -> list[str]:
2663
3642
  return []
2664
3643
  if _is_cli_entry_point(file_path):
2665
3644
  return []
2666
- if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
3645
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
2667
3646
  return []
2668
3647
  try:
2669
3648
  tree = ast.parse(content)
@@ -2688,13 +3667,6 @@ def check_library_print(content: str, file_path: str) -> list[str]:
2688
3667
  return issues
2689
3668
 
2690
3669
 
2691
- SELF_AND_CLS_PARAMETER_NAMES: frozenset[str] = frozenset({"self", "cls"})
2692
- LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
2693
- EACH_PREFIX = "each_"
2694
- BARE_EACH_TOKEN = "each"
2695
- INLINE_COLLECTION_MIN_LENGTH = 3
2696
- ALL_CAPS_WITH_UNDERSCORE_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$")
2697
- DOTTED_SEGMENT_PATTERN = re.compile(r"^\.[a-z][a-z0-9_]*$")
2698
3670
 
2699
3671
 
2700
3672
  def _is_magic_string_literal(string_value: str) -> bool:
@@ -2830,6 +3802,58 @@ def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
2830
3802
  return issues
2831
3803
 
2832
3804
 
3805
+ def check_inline_tuple_string_magic(content: str, file_path: str) -> list[str]:
3806
+ """Flag inline two-tuple literals whose first element is a snake_case string.
3807
+
3808
+ Catches the pattern ``("kept", "Unknown status")`` and similar
3809
+ column-name/key-value pairs declared inside function bodies. Files under
3810
+ ``config/`` and test files are exempt because that is where named
3811
+ constants are expected to live.
3812
+ """
3813
+ if is_test_file(file_path):
3814
+ return []
3815
+ if is_config_file(file_path):
3816
+ return []
3817
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
3818
+ return []
3819
+ try:
3820
+ tree = ast.parse(content)
3821
+ except SyntaxError:
3822
+ return []
3823
+ snake_case_pattern = re.compile(SNAKE_CASE_LITERAL_PATTERN)
3824
+ issues: list[str] = []
3825
+ seen_tuple_node_ids: set[int] = set()
3826
+ for each_function_node in ast.walk(tree):
3827
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
3828
+ continue
3829
+ for each_body_statement in each_function_node.body:
3830
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
3831
+ if not isinstance(each_descendant, ast.Tuple):
3832
+ continue
3833
+ if id(each_descendant) in seen_tuple_node_ids:
3834
+ continue
3835
+ seen_tuple_node_ids.add(id(each_descendant))
3836
+ if len(each_descendant.elts) != EXPECTED_TUPLE_PAIR_LENGTH:
3837
+ continue
3838
+ first_element = each_descendant.elts[0]
3839
+ if not isinstance(first_element, ast.Constant):
3840
+ continue
3841
+ if not isinstance(first_element.value, str):
3842
+ continue
3843
+ literal_text = first_element.value
3844
+ if not snake_case_pattern.match(literal_text):
3845
+ continue
3846
+ if literal_text in ALL_SNAKE_CASE_KEYWORD_EXEMPTIONS:
3847
+ continue
3848
+ issues.append(
3849
+ f"Line {first_element.lineno}: Column-name string magic "
3850
+ f"{literal_text!r} - {INLINE_TUPLE_STRING_MAGIC_MESSAGE_SUFFIX}"
3851
+ )
3852
+ if len(issues) >= MAX_INLINE_TUPLE_STRING_MAGIC_ISSUES:
3853
+ return issues
3854
+ return issues
3855
+
3856
+
2833
3857
  def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
2834
3858
  if is_test_file(file_path):
2835
3859
  return []
@@ -2845,7 +3869,7 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
2845
3869
  continue
2846
3870
  for each_name_node in _collect_target_names(node.target):
2847
3871
  target_name = each_name_node.id
2848
- if target_name in LOOP_INDEX_LETTER_EXEMPTIONS:
3872
+ if target_name in ALL_LOOP_INDEX_LETTER_EXEMPTIONS:
2849
3873
  continue
2850
3874
  if target_name == BARE_EACH_TOKEN:
2851
3875
  issues.append(
@@ -2875,7 +3899,7 @@ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
2875
3899
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2876
3900
  continue
2877
3901
  for each_arg in _collect_annotated_arguments(node):
2878
- if each_arg.arg in SELF_AND_CLS_PARAMETER_NAMES:
3902
+ if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
2879
3903
  continue
2880
3904
  if each_arg.annotation is None:
2881
3905
  issues.append(
@@ -2904,19 +3928,31 @@ def check_return_annotations(content: str, file_path: str) -> list[str]:
2904
3928
  return issues
2905
3929
 
2906
3930
 
2907
- def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
3931
+ def validate_content(
3932
+ content: str,
3933
+ file_path: str,
3934
+ old_content: str = "",
3935
+ full_file_content: str | None = None,
3936
+ ) -> list[str]:
2908
3937
  """Run all applicable validators on content.
2909
3938
 
2910
3939
  Args:
2911
- content: The new content being written.
3940
+ content: The new content being written. For Edit, this is the
3941
+ ``new_string`` fragment; for Write, the entire new file body.
2912
3942
  file_path: Path to the file.
2913
3943
  old_content: Previous content (old_string for Edit, existing file for Write).
2914
3944
  Used to detect comment additions/removals instead of flagging all comments.
3945
+ full_file_content: For Edit operations, the reconstructed post-edit
3946
+ content of the entire file (existing file with ``old_string`` replaced
3947
+ by ``new_string``). Whole-file checks such as the unused-import
3948
+ scanner use this to evaluate references across the file rather than
3949
+ just within the inserted fragment.
2915
3950
  """
2916
3951
  extension = get_file_extension(file_path)
2917
3952
  all_issues = []
3953
+ effective_content = content if full_file_content is None else full_file_content
2918
3954
 
2919
- if extension in PYTHON_EXTENSIONS:
3955
+ if extension in ALL_PYTHON_EXTENSIONS:
2920
3956
  if not is_test_file(file_path):
2921
3957
  all_issues.extend(check_comment_changes(old_content, content, file_path))
2922
3958
  all_issues.extend(check_imports_at_top(content))
@@ -2927,8 +3963,16 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2927
3963
  all_issues.extend(check_constants_outside_config(content, file_path))
2928
3964
  all_issues.extend(check_constants_outside_config_advisory(content, file_path))
2929
3965
  all_issues.extend(check_file_global_constants_use_count(content, file_path))
2930
- all_issues.extend(check_type_escape_hatches(content, file_path))
3966
+ all_issues.extend(check_type_escape_hatches(effective_content, file_path))
2931
3967
  all_issues.extend(check_banned_identifiers(content, file_path))
3968
+ all_issues.extend(check_banned_prefixes(effective_content, file_path))
3969
+ all_issues.extend(check_stub_implementations(effective_content, file_path))
3970
+ all_issues.extend(check_typed_dict_encode_decode(effective_content, file_path))
3971
+ all_issues.extend(check_test_branching_in_production(effective_content, file_path))
3972
+ all_issues.extend(check_bare_except(effective_content, file_path))
3973
+ all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
3974
+ all_issues.extend(check_boundary_types(effective_content, file_path))
3975
+ all_issues.extend(check_docstring_format(effective_content, file_path))
2932
3976
  all_issues.extend(check_boolean_naming(content, file_path))
2933
3977
  all_issues.extend(check_skip_decorators_in_tests(content, file_path))
2934
3978
  all_issues.extend(check_existence_check_tests(content, file_path))
@@ -2938,17 +3982,20 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2938
3982
  all_issues.extend(check_stuttering_collection_prefix(content, file_path))
2939
3983
  all_issues.extend(check_hardcoded_user_paths(content, file_path))
2940
3984
  all_issues.extend(check_sys_path_insert_deduplication_guard(content, file_path))
2941
- all_issues.extend(check_unused_module_level_imports(content, file_path))
3985
+ all_issues.extend(
3986
+ check_unused_module_level_imports(content, file_path, full_file_content)
3987
+ )
2942
3988
  all_issues.extend(check_library_print(content, file_path))
2943
3989
  all_issues.extend(check_parameter_annotations(content, file_path))
2944
3990
  all_issues.extend(check_return_annotations(content, file_path))
2945
3991
  all_issues.extend(check_loop_variable_naming(content, file_path))
2946
3992
  all_issues.extend(check_inline_literal_collections(content, file_path))
3993
+ all_issues.extend(check_inline_tuple_string_magic(content, file_path))
2947
3994
  all_issues.extend(check_string_literal_magic(content, file_path))
2948
3995
  check_incomplete_mocks(content, file_path)
2949
3996
  check_duplicated_format_patterns(content, file_path)
2950
3997
 
2951
- elif extension in JAVASCRIPT_EXTENSIONS:
3998
+ elif extension in ALL_JAVASCRIPT_EXTENSIONS:
2952
3999
  if not is_test_file(file_path):
2953
4000
  all_issues.extend(check_comment_changes(old_content, content, file_path))
2954
4001
  all_issues.extend(check_e2e_test_naming(content, file_path))
@@ -2959,6 +4006,30 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2959
4006
  return all_issues
2960
4007
 
2961
4008
 
4009
+ def _reconstruct_post_edit_file_content(
4010
+ file_path: str, old_string: str, new_string: str,
4011
+ ) -> str | None:
4012
+ """Return the file content as it will look after the Edit applies, or None.
4013
+
4014
+ Reads ``file_path`` from disk and replaces the first occurrence of
4015
+ ``old_string`` with ``new_string``, mirroring how the Edit tool itself
4016
+ applies a single replacement. Returns None when the file cannot be read,
4017
+ ``old_string`` is empty, or ``old_string`` is not present in the existing
4018
+ file (which means the Edit will fail or has already been applied — neither
4019
+ case yields a well-defined post-edit view).
4020
+ """
4021
+ if not old_string:
4022
+ return None
4023
+ try:
4024
+ with open(file_path, "r", encoding="utf-8") as existing_file:
4025
+ existing_content = existing_file.read()
4026
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
4027
+ return None
4028
+ if old_string not in existing_content:
4029
+ return None
4030
+ return existing_content.replace(old_string, new_string, 1)
4031
+
4032
+
2962
4033
  def main() -> None:
2963
4034
  try:
2964
4035
  input_data = json.load(sys.stdin)
@@ -2980,9 +4051,13 @@ def main() -> None:
2980
4051
  sys.exit(0)
2981
4052
 
2982
4053
  old_content = ""
4054
+ full_file_content_after_edit: str | None = None
2983
4055
  if tool_name == "Edit":
2984
4056
  content = tool_input.get("new_string", "")
2985
4057
  old_content = tool_input.get("old_string", "")
4058
+ full_file_content_after_edit = _reconstruct_post_edit_file_content(
4059
+ file_path, old_content, content,
4060
+ )
2986
4061
  else:
2987
4062
  content = tool_input.get("content", "") or tool_input.get("new_string", "")
2988
4063
  try:
@@ -2997,18 +4072,18 @@ def main() -> None:
2997
4072
  if not content:
2998
4073
  sys.exit(0)
2999
4074
 
3000
- issues = validate_content(content, file_path, old_content)
4075
+ issues = validate_content(content, file_path, old_content, full_file_content_after_edit)
3001
4076
 
3002
4077
  if issues:
3003
4078
  issue_list = "; ".join(issues[:10])
3004
- result = {
4079
+ deny_payload = {
3005
4080
  "hookSpecificOutput": {
3006
4081
  "hookEventName": "PreToolUse",
3007
4082
  "permissionDecision": "deny",
3008
4083
  "permissionDecisionReason": f"BLOCKED: [CODE_RULES] {len(issues)} violation(s): {issue_list}",
3009
4084
  }
3010
4085
  }
3011
- print(json.dumps(result))
4086
+ print(json.dumps(deny_payload))
3012
4087
  sys.stdout.flush()
3013
4088
 
3014
4089
  sys.exit(0)