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.
- package/CLAUDE.md +10 -36
- package/_shared/pr-loop/audit-reply-template.md +147 -0
- package/_shared/pr-loop/fix-protocol.md +25 -4
- package/_shared/pr-loop/gh-payloads.md +37 -50
- package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +199 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/state-schema.md +1 -1
- package/agents/clean-coder.md +2 -2
- package/agents/pr-description-writer.md +150 -52
- package/bin/install.mjs +6 -7
- package/bin/install.test.mjs +8 -0
- package/commands/doc-gist.md +16 -0
- package/commands/plan.md +0 -2
- package/commands/review-plan.md +1 -1
- package/docs/CODE_RULES.md +122 -2
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
- package/hooks/blocking/code_rules_enforcer.py +1143 -129
- package/hooks/blocking/convergence_gate_blocker.py +130 -0
- package/hooks/blocking/destructive_command_blocker.py +74 -0
- package/hooks/blocking/gh_body_arg_blocker.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +119 -0
- package/hooks/blocking/pr_description_enforcer.py +57 -22
- package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
- package/hooks/blocking/test_code_rules_enforcer.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
- package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
- package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
- package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
- package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
- package/hooks/blocking/test_destructive_command_blocker.py +146 -0
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
- package/hooks/blocking/test_md_to_html_blocker.py +317 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/any_type_config.py +7 -0
- package/hooks/config/banned_identifiers_constants.py +11 -0
- package/hooks/config/blocking_check_limits.py +38 -0
- package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
- package/hooks/config/code_rules_enforcer_constants.py +53 -0
- package/hooks/config/convergence_branch_constants.py +9 -0
- package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
- package/hooks/config/html_companion_constants.py +20 -0
- package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
- package/hooks/config/pr_description_enforcer_constants.py +14 -0
- package/hooks/config/test_banned_identifiers_constants.py +17 -0
- package/hooks/hooks.json +28 -20
- package/hooks/pyproject.toml +69 -0
- package/hooks/validators/mypy_integration.py +47 -1
- package/hooks/validators/run_all_validators.py +3 -3
- package/hooks/validators/test_mypy_integration.py +50 -1
- package/hooks/workflow/doc_gist_auto_publish.py +144 -0
- package/hooks/workflow/md_to_html_companion.py +365 -0
- package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
- package/hooks/workflow/test_md_to_html_companion.py +452 -0
- package/package.json +1 -1
- package/rules/gh-body-file.md +2 -0
- package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
- package/scripts/check.ps1 +106 -0
- package/scripts/config/timing.py +11 -0
- package/scripts/sweep_empty_dirs.py +138 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_sweep_empty_dirs.py +183 -0
- package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
- package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
- package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
- package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
- package/skills/bugteam/CONSTRAINTS.md +21 -22
- package/skills/bugteam/EXAMPLES.md +3 -3
- package/skills/bugteam/PROMPTS.md +227 -67
- package/skills/bugteam/SKILL.md +132 -455
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +112 -39
- package/skills/bugteam/reference/audit-contract.md +4 -22
- package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
- package/skills/bugteam/reference/design-rationale.md +2 -2
- package/skills/bugteam/reference/github-pr-reviews.md +50 -57
- package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
- package/skills/bugteam/reference/team-setup.md +111 -9
- package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
- package/skills/bugteam/scripts/README.md +60 -0
- package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
- package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
- package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
- package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
- package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
- package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
- package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
- package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
- package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
- package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
- package/skills/bugteam/test_skill_additions.py +1 -11
- package/skills/code/SKILL.md +176 -0
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/doc-gist/SKILL.md +99 -0
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
- package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
- package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
- package/skills/doc-gist/references/examples/05-design-system.html +629 -0
- package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
- package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
- package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
- package/skills/doc-gist/references/examples/11-status-report.html +528 -0
- package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
- package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
- package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
- package/skills/doc-gist/references/examples/README.md +5 -0
- package/skills/doc-gist/scripts/config/__init__.py +0 -0
- package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
- package/skills/doc-gist/scripts/gist_upload.py +177 -0
- package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
- package/skills/findbugs/SKILL.md +96 -2
- package/skills/monitor-open-prs/SKILL.md +14 -32
- package/skills/monitor-open-prs/test_skill_contract.py +0 -11
- package/skills/pr-consistency-audit/SKILL.md +112 -0
- package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
- package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
- package/skills/pr-converge/SKILL.md +229 -23
- package/skills/pr-converge/config/__init__.py +0 -0
- package/skills/pr-converge/config/constants.py +63 -0
- package/skills/pr-converge/reference/convergence-gates.md +138 -44
- package/skills/pr-converge/reference/examples.md +43 -11
- package/skills/pr-converge/reference/fix-protocol.md +6 -5
- package/skills/pr-converge/reference/ground-rules.md +5 -3
- package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
- package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
- package/skills/pr-converge/reference/per-tick.md +107 -31
- package/skills/pr-converge/reference/state-schema.md +22 -1
- package/skills/pr-converge/reference/stop-conditions.md +9 -7
- package/skills/pr-converge/scripts/README.md +34 -46
- package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
- package/skills/pr-converge/scripts/check_convergence.py +497 -0
- package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
- package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
- package/skills/qbug/SKILL.md +157 -27
- package/skills/session-log/SKILL.md +216 -114
- package/skills/session-tidy/SKILL.md +1 -1
- package/skills/skill-builder/SKILL.md +138 -56
- package/skills/skill-builder/references/delegation-map.md +72 -113
- package/skills/skill-builder/references/progressive-disclosure.md +122 -0
- package/skills/skill-builder/references/self-audit-checklist.md +92 -0
- package/skills/skill-builder/references/skill-types.md +228 -0
- package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
- package/skills/skill-builder/templates/gap-analysis.md +15 -8
- package/skills/skill-builder/workflows/improve-skill.md +86 -57
- package/skills/skill-builder/workflows/new-skill.md +80 -168
- package/skills/skill-builder/workflows/polish-skill.md +78 -54
- package/skills/structure-prompt/SKILL.md +50 -0
- package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
- package/skills/structure-prompt/reference/block-classification.md +27 -0
- package/skills/structure-prompt/reference/canonical-case.md +48 -0
- package/skills/structure-prompt/reference/citation-depth.md +70 -0
- package/skills/structure-prompt/reference/cleanup.md +33 -0
- package/skills/structure-prompt/reference/constraints.md +33 -0
- package/skills/structure-prompt/reference/directives.md +37 -0
- package/skills/structure-prompt/reference/examples.md +72 -0
- package/skills/structure-prompt/reference/instantiation.md +51 -0
- package/skills/structure-prompt/reference/output-contract.md +72 -0
- package/skills/structure-prompt/reference/per-category.md +23 -0
- package/skills/structure-prompt/reference/persona.md +38 -0
- package/skills/structure-prompt/reference/research.md +33 -0
- package/skills/structure-prompt/reference/structure.md +28 -0
- package/agents/code-standards-agent.md +0 -93
- package/agents/groq-coder.md +0 -113
- package/agents/plan-executor.md +0 -226
- package/agents/project-docs-analyzer.md +0 -53
- package/agents/project-structure-organizer-agent.md +0 -72
- package/agents/skill-to-agent-converter.md +0 -370
- package/agents/skill-writer-agent.md +0 -470
- package/agents/user-docs-writer.md +0 -67
- package/agents/workflow-visual-documenter.md +0 -82
- package/commands/readability-review.md +0 -20
- package/hooks/mypy.ini +0 -2
- package/hooks/notification/attention_needed_notify.py +0 -71
- package/hooks/notification/claude_notification_handler.py +0 -67
- package/hooks/notification/notification_utils.py +0 -267
- package/hooks/notification/subagent_complete_notify.py +0 -381
- package/hooks/notification/test_attention_needed_notify.py +0 -47
- package/hooks/notification/test_claude_notification_handler.py +0 -54
- package/hooks/notification/test_notification_utils.py +0 -91
- package/hooks/notification/test_subagent_complete_notify.py +0 -79
- package/scripts/config/groq_bugteam_config.py +0 -230
- package/scripts/config/test_groq_bugteam_config.py +0 -83
- package/scripts/config/test_spec_implementer_prompt.py +0 -32
- package/scripts/groq_bugteam.README.md +0 -131
- package/scripts/groq_bugteam.py +0 -647
- package/scripts/groq_bugteam_dotenv.py +0 -40
- package/scripts/groq_bugteam_spec.py +0 -226
- package/scripts/test_groq_bugteam.py +0 -529
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
- package/scripts/test_groq_bugteam_dotenv.py +0 -66
- package/scripts/test_groq_bugteam_spec.py +0 -338
- package/skills/bugteam/SKILL_EVALS.md +0 -309
- package/skills/dream/SKILL.md +0 -118
- package/skills/ingest/SKILL.md +0 -40
- package/skills/npm-creator/SKILL.md +0 -187
- package/skills/readability-review/SKILL.md +0 -127
- package/skills/resume-review/SKILL.md +0 -261
- package/skills/rule-audit/SKILL.md +0 -307
- package/skills/rule-creator/SKILL.md +0 -150
- package/skills/searching-obsidian-vault/SKILL.md +0 -131
- package/skills/skill-writer/REFERENCE.md +0 -284
- package/skills/skill-writer/SKILL.md +0 -222
- 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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
203
|
+
is_in_string = False
|
|
171
204
|
quote_char = None
|
|
172
|
-
for i,
|
|
173
|
-
if
|
|
174
|
-
if not
|
|
175
|
-
|
|
176
|
-
quote_char =
|
|
177
|
-
elif
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if not
|
|
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
|
-
|
|
228
|
+
is_in_multiline_comment = False
|
|
196
229
|
|
|
197
|
-
for
|
|
198
|
-
stripped =
|
|
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
|
|
236
|
+
if is_in_multiline_comment:
|
|
204
237
|
if "*/" in stripped:
|
|
205
|
-
|
|
238
|
+
is_in_multiline_comment = False
|
|
206
239
|
continue
|
|
207
240
|
|
|
208
241
|
if stripped.startswith("/*"):
|
|
209
|
-
|
|
242
|
+
is_in_multiline_comment = "*/" not in stripped
|
|
210
243
|
if not stripped.startswith("/**"):
|
|
211
|
-
issues.append(f"Line {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
258
|
-
|
|
290
|
+
if not is_in_string:
|
|
291
|
+
is_in_string = True
|
|
259
292
|
quote_char = char
|
|
260
293
|
elif char == quote_char:
|
|
261
|
-
|
|
262
|
-
if not
|
|
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
|
|
268
|
-
|
|
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
|
|
306
|
+
if is_in_multiline:
|
|
274
307
|
if "*/" in stripped:
|
|
275
|
-
|
|
308
|
+
is_in_multiline = False
|
|
276
309
|
continue
|
|
277
310
|
if stripped.startswith("/*"):
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
415
|
+
if is_inside_function:
|
|
380
416
|
if current_indent <= function_indent and stripped and not stripped.startswith(("#", "@", ")")):
|
|
381
|
-
|
|
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
|
|
385
|
-
if stripped.startswith(
|
|
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
|
-
|
|
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
|
-
|
|
526
|
+
is_inside_function = True
|
|
498
527
|
continue
|
|
499
528
|
|
|
500
529
|
if re.match(r"^class\s+\w+", stripped):
|
|
501
|
-
|
|
530
|
+
is_inside_function = False
|
|
502
531
|
continue
|
|
503
532
|
|
|
504
|
-
if
|
|
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
|
|
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
|
|
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
|
-
|
|
753
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
944
|
+
is_inside_function = True
|
|
798
945
|
continue
|
|
799
946
|
|
|
800
947
|
if re.match(r"^class\s+\w+", stripped):
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
808
|
-
|
|
954
|
+
is_inside_function = False
|
|
955
|
+
is_inside_class = False
|
|
809
956
|
|
|
810
|
-
if not
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
2929
|
+
return annotation_node.id in ALL_COLLECTION_TYPE_NAMES
|
|
1977
2930
|
if isinstance(annotation_node, ast.Attribute):
|
|
1978
|
-
return annotation_node.attr in
|
|
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
|
|
1988
|
-
or (isinstance(outer_value, ast.Attribute) and outer_value.attr in
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
4086
|
+
print(json.dumps(deny_payload))
|
|
3073
4087
|
sys.stdout.flush()
|
|
3074
4088
|
|
|
3075
4089
|
sys.exit(0)
|