claude-dev-env 1.38.1 → 1.40.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 (282) 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 +199 -0
  7. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  8. package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
  9. package/_shared/pr-loop/scripts/preflight.py +129 -2
  10. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  12. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
  13. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  14. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  15. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  16. package/_shared/pr-loop/state-schema.md +1 -1
  17. package/agents/clean-coder.md +2 -2
  18. package/agents/pr-description-writer.md +150 -52
  19. package/bin/install.mjs +6 -7
  20. package/bin/install.test.mjs +8 -0
  21. package/commands/doc-gist.md +16 -0
  22. package/commands/plan.md +0 -2
  23. package/commands/review-plan.md +1 -1
  24. package/docs/CODE_RULES.md +122 -2
  25. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  26. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  27. package/hooks/blocking/code_rules_enforcer.py +1143 -129
  28. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  29. package/hooks/blocking/destructive_command_blocker.py +74 -0
  30. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  31. package/hooks/blocking/md_to_html_blocker.py +119 -0
  32. package/hooks/blocking/pr_description_enforcer.py +57 -22
  33. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  34. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  35. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  36. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  38. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  39. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  40. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  41. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  42. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  44. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  45. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  46. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  47. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  48. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  49. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  50. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  51. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  52. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  53. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  54. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  55. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  56. package/hooks/config/any_type_config.py +7 -0
  57. package/hooks/config/banned_identifiers_constants.py +11 -0
  58. package/hooks/config/blocking_check_limits.py +38 -0
  59. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  60. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  61. package/hooks/config/convergence_branch_constants.py +9 -0
  62. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  63. package/hooks/config/html_companion_constants.py +20 -0
  64. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  65. package/hooks/config/pr_description_enforcer_constants.py +14 -0
  66. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  67. package/hooks/hooks.json +28 -20
  68. package/hooks/pyproject.toml +69 -0
  69. package/hooks/validators/mypy_integration.py +47 -1
  70. package/hooks/validators/run_all_validators.py +3 -3
  71. package/hooks/validators/test_mypy_integration.py +50 -1
  72. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  73. package/hooks/workflow/md_to_html_companion.py +365 -0
  74. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  75. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  76. package/package.json +1 -1
  77. package/rules/gh-body-file.md +2 -0
  78. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  79. package/scripts/check.ps1 +106 -0
  80. package/scripts/config/timing.py +11 -0
  81. package/scripts/sweep_empty_dirs.py +138 -0
  82. package/scripts/sync_to_cursor/rules.py +1 -1
  83. package/scripts/test_sweep_empty_dirs.py +183 -0
  84. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  85. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  86. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  87. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  88. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  89. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  90. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  91. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  92. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  93. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  94. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  95. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  96. package/skills/bugteam/CONSTRAINTS.md +21 -22
  97. package/skills/bugteam/EXAMPLES.md +3 -3
  98. package/skills/bugteam/PROMPTS.md +227 -67
  99. package/skills/bugteam/SKILL.md +132 -455
  100. package/skills/bugteam/reference/README.md +1 -1
  101. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  102. package/skills/bugteam/reference/audit-contract.md +4 -22
  103. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  104. package/skills/bugteam/reference/design-rationale.md +2 -2
  105. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  106. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  107. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  108. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  109. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  112. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  113. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  114. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  115. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  116. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  117. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  118. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  119. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  120. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  121. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  122. package/skills/bugteam/reference/team-setup.md +111 -9
  123. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  124. package/skills/bugteam/scripts/README.md +60 -0
  125. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  126. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  127. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  128. package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
  129. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  130. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  131. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  132. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  133. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  134. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  135. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  136. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  137. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  138. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  139. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  140. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  141. package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
  142. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  143. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  144. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  145. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  146. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  147. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  148. package/skills/bugteam/test_skill_additions.py +1 -11
  149. package/skills/code/SKILL.md +176 -0
  150. package/skills/copilot-review/SKILL.md +16 -0
  151. package/skills/doc-gist/SKILL.md +99 -0
  152. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  153. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  154. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  155. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  156. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  157. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  158. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  159. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  160. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  161. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  162. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  163. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  164. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  165. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  166. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  167. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  168. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  169. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  170. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  171. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  172. package/skills/doc-gist/references/examples/README.md +5 -0
  173. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  174. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  175. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  176. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  177. package/skills/findbugs/SKILL.md +96 -2
  178. package/skills/monitor-open-prs/SKILL.md +14 -32
  179. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  180. package/skills/pr-consistency-audit/SKILL.md +112 -0
  181. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  182. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  183. package/skills/pr-converge/SKILL.md +229 -23
  184. package/skills/pr-converge/config/__init__.py +0 -0
  185. package/skills/pr-converge/config/constants.py +63 -0
  186. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  187. package/skills/pr-converge/reference/examples.md +43 -11
  188. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  189. package/skills/pr-converge/reference/ground-rules.md +5 -3
  190. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  191. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  192. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  193. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  194. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  195. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  196. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  197. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  198. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  199. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  200. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  201. package/skills/pr-converge/reference/per-tick.md +107 -31
  202. package/skills/pr-converge/reference/state-schema.md +22 -1
  203. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  204. package/skills/pr-converge/scripts/README.md +34 -46
  205. package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
  206. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  207. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  208. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  209. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  210. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  211. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  212. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  213. package/skills/qbug/SKILL.md +157 -27
  214. package/skills/session-log/SKILL.md +216 -114
  215. package/skills/session-tidy/SKILL.md +1 -1
  216. package/skills/skill-builder/SKILL.md +138 -56
  217. package/skills/skill-builder/references/delegation-map.md +72 -113
  218. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  219. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  220. package/skills/skill-builder/references/skill-types.md +228 -0
  221. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  222. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  223. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  224. package/skills/skill-builder/workflows/new-skill.md +80 -168
  225. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  226. package/skills/structure-prompt/SKILL.md +50 -0
  227. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  228. package/skills/structure-prompt/reference/block-classification.md +27 -0
  229. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  230. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  231. package/skills/structure-prompt/reference/cleanup.md +33 -0
  232. package/skills/structure-prompt/reference/constraints.md +33 -0
  233. package/skills/structure-prompt/reference/directives.md +37 -0
  234. package/skills/structure-prompt/reference/examples.md +72 -0
  235. package/skills/structure-prompt/reference/instantiation.md +51 -0
  236. package/skills/structure-prompt/reference/output-contract.md +72 -0
  237. package/skills/structure-prompt/reference/per-category.md +23 -0
  238. package/skills/structure-prompt/reference/persona.md +38 -0
  239. package/skills/structure-prompt/reference/research.md +33 -0
  240. package/skills/structure-prompt/reference/structure.md +28 -0
  241. package/agents/code-standards-agent.md +0 -93
  242. package/agents/groq-coder.md +0 -113
  243. package/agents/plan-executor.md +0 -226
  244. package/agents/project-docs-analyzer.md +0 -53
  245. package/agents/project-structure-organizer-agent.md +0 -72
  246. package/agents/skill-to-agent-converter.md +0 -370
  247. package/agents/skill-writer-agent.md +0 -470
  248. package/agents/user-docs-writer.md +0 -67
  249. package/agents/workflow-visual-documenter.md +0 -82
  250. package/commands/readability-review.md +0 -20
  251. package/hooks/mypy.ini +0 -2
  252. package/hooks/notification/attention_needed_notify.py +0 -71
  253. package/hooks/notification/claude_notification_handler.py +0 -67
  254. package/hooks/notification/notification_utils.py +0 -267
  255. package/hooks/notification/subagent_complete_notify.py +0 -381
  256. package/hooks/notification/test_attention_needed_notify.py +0 -47
  257. package/hooks/notification/test_claude_notification_handler.py +0 -54
  258. package/hooks/notification/test_notification_utils.py +0 -91
  259. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  260. package/scripts/config/groq_bugteam_config.py +0 -230
  261. package/scripts/config/test_groq_bugteam_config.py +0 -83
  262. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  263. package/scripts/groq_bugteam.README.md +0 -131
  264. package/scripts/groq_bugteam.py +0 -647
  265. package/scripts/groq_bugteam_dotenv.py +0 -40
  266. package/scripts/groq_bugteam_spec.py +0 -226
  267. package/scripts/test_groq_bugteam.py +0 -529
  268. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  269. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  270. package/scripts/test_groq_bugteam_spec.py +0 -338
  271. package/skills/bugteam/SKILL_EVALS.md +0 -309
  272. package/skills/dream/SKILL.md +0 -118
  273. package/skills/ingest/SKILL.md +0 -40
  274. package/skills/npm-creator/SKILL.md +0 -187
  275. package/skills/readability-review/SKILL.md +0 -127
  276. package/skills/resume-review/SKILL.md +0 -261
  277. package/skills/rule-audit/SKILL.md +0 -307
  278. package/skills/rule-creator/SKILL.md +0 -150
  279. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  280. package/skills/skill-writer/REFERENCE.md +0 -284
  281. package/skills/skill-writer/SKILL.md +0 -222
  282. 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])
751
885
 
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")
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])
892
+
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
 
@@ -2675,7 +3634,7 @@ def check_unused_module_level_imports(
2675
3634
 
2676
3635
  def _is_cli_entry_point(file_path: str) -> bool:
2677
3636
  path_lower = file_path.lower().replace("\\", "/")
2678
- 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)
2679
3638
 
2680
3639
 
2681
3640
  def check_library_print(content: str, file_path: str) -> list[str]:
@@ -2683,7 +3642,7 @@ def check_library_print(content: str, file_path: str) -> list[str]:
2683
3642
  return []
2684
3643
  if _is_cli_entry_point(file_path):
2685
3644
  return []
2686
- if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
3645
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
2687
3646
  return []
2688
3647
  try:
2689
3648
  tree = ast.parse(content)
@@ -2708,13 +3667,6 @@ def check_library_print(content: str, file_path: str) -> list[str]:
2708
3667
  return issues
2709
3668
 
2710
3669
 
2711
- SELF_AND_CLS_PARAMETER_NAMES: frozenset[str] = frozenset({"self", "cls"})
2712
- LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
2713
- EACH_PREFIX = "each_"
2714
- BARE_EACH_TOKEN = "each"
2715
- INLINE_COLLECTION_MIN_LENGTH = 3
2716
- ALL_CAPS_WITH_UNDERSCORE_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$")
2717
- DOTTED_SEGMENT_PATTERN = re.compile(r"^\.[a-z][a-z0-9_]*$")
2718
3670
 
2719
3671
 
2720
3672
  def _is_magic_string_literal(string_value: str) -> bool:
@@ -2850,6 +3802,58 @@ def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
2850
3802
  return issues
2851
3803
 
2852
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
+
2853
3857
  def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
2854
3858
  if is_test_file(file_path):
2855
3859
  return []
@@ -2865,7 +3869,7 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
2865
3869
  continue
2866
3870
  for each_name_node in _collect_target_names(node.target):
2867
3871
  target_name = each_name_node.id
2868
- if target_name in LOOP_INDEX_LETTER_EXEMPTIONS:
3872
+ if target_name in ALL_LOOP_INDEX_LETTER_EXEMPTIONS:
2869
3873
  continue
2870
3874
  if target_name == BARE_EACH_TOKEN:
2871
3875
  issues.append(
@@ -2895,7 +3899,7 @@ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
2895
3899
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2896
3900
  continue
2897
3901
  for each_arg in _collect_annotated_arguments(node):
2898
- if each_arg.arg in SELF_AND_CLS_PARAMETER_NAMES:
3902
+ if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
2899
3903
  continue
2900
3904
  if each_arg.annotation is None:
2901
3905
  issues.append(
@@ -2946,8 +3950,9 @@ def validate_content(
2946
3950
  """
2947
3951
  extension = get_file_extension(file_path)
2948
3952
  all_issues = []
3953
+ effective_content = content if full_file_content is None else full_file_content
2949
3954
 
2950
- if extension in PYTHON_EXTENSIONS:
3955
+ if extension in ALL_PYTHON_EXTENSIONS:
2951
3956
  if not is_test_file(file_path):
2952
3957
  all_issues.extend(check_comment_changes(old_content, content, file_path))
2953
3958
  all_issues.extend(check_imports_at_top(content))
@@ -2958,8 +3963,16 @@ def validate_content(
2958
3963
  all_issues.extend(check_constants_outside_config(content, file_path))
2959
3964
  all_issues.extend(check_constants_outside_config_advisory(content, file_path))
2960
3965
  all_issues.extend(check_file_global_constants_use_count(content, file_path))
2961
- all_issues.extend(check_type_escape_hatches(content, file_path))
3966
+ all_issues.extend(check_type_escape_hatches(effective_content, file_path))
2962
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))
2963
3976
  all_issues.extend(check_boolean_naming(content, file_path))
2964
3977
  all_issues.extend(check_skip_decorators_in_tests(content, file_path))
2965
3978
  all_issues.extend(check_existence_check_tests(content, file_path))
@@ -2977,11 +3990,12 @@ def validate_content(
2977
3990
  all_issues.extend(check_return_annotations(content, file_path))
2978
3991
  all_issues.extend(check_loop_variable_naming(content, file_path))
2979
3992
  all_issues.extend(check_inline_literal_collections(content, file_path))
3993
+ all_issues.extend(check_inline_tuple_string_magic(content, file_path))
2980
3994
  all_issues.extend(check_string_literal_magic(content, file_path))
2981
3995
  check_incomplete_mocks(content, file_path)
2982
3996
  check_duplicated_format_patterns(content, file_path)
2983
3997
 
2984
- elif extension in JAVASCRIPT_EXTENSIONS:
3998
+ elif extension in ALL_JAVASCRIPT_EXTENSIONS:
2985
3999
  if not is_test_file(file_path):
2986
4000
  all_issues.extend(check_comment_changes(old_content, content, file_path))
2987
4001
  all_issues.extend(check_e2e_test_naming(content, file_path))
@@ -3062,14 +4076,14 @@ def main() -> None:
3062
4076
 
3063
4077
  if issues:
3064
4078
  issue_list = "; ".join(issues[:10])
3065
- result = {
4079
+ deny_payload = {
3066
4080
  "hookSpecificOutput": {
3067
4081
  "hookEventName": "PreToolUse",
3068
4082
  "permissionDecision": "deny",
3069
4083
  "permissionDecisionReason": f"BLOCKED: [CODE_RULES] {len(issues)} violation(s): {issue_list}",
3070
4084
  }
3071
4085
  }
3072
- print(json.dumps(result))
4086
+ print(json.dumps(deny_payload))
3073
4087
  sys.stdout.flush()
3074
4088
 
3075
4089
  sys.exit(0)