claude-dev-env 2.28.0 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/.agents/agents/AGENTS.md +0 -31
  2. package/.agents/agents/clean-coder.md +1 -1
  3. package/.agents/agents/test_agent_frontmatter.py +3 -1
  4. package/.agents/skills/AGENTS.md +0 -70
  5. package/.agents/skills/_shared/AGENTS.md +0 -44
  6. package/.agents/skills/_shared/advisor/AGENTS.md +0 -8
  7. package/.agents/skills/_shared/pr-loop/AGENTS.md +0 -57
  8. package/.agents/skills/_shared/pr-loop/prompts/AGENTS.md +0 -8
  9. package/.agents/skills/_shared/pr-loop/scripts/AGENTS.md +0 -34
  10. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/AGENTS.md +0 -23
  11. package/.agents/skills/autoconverge/AGENTS.md +0 -35
  12. package/.agents/skills/autoconverge/reference/AGENTS.md +0 -15
  13. package/.agents/skills/autoconverge/workflow/AGENTS.md +0 -25
  14. package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/AGENTS.md +0 -15
  15. package/.agents/skills/everything-search/AGENTS.md +0 -16
  16. package/.agents/skills/fresh-branch/AGENTS.md +0 -13
  17. package/.agents/skills/grok-spawn/AGENTS.md +0 -27
  18. package/.agents/skills/orchestrator/.claude/CLAUDE.md +1 -0
  19. package/.agents/skills/orchestrator/AGENTS.md +1 -0
  20. package/.agents/skills/orchestrator/SKILL.md +44 -53
  21. package/.agents/skills/orchestrator/reference/.claude/CLAUDE.md +1 -0
  22. package/.agents/skills/orchestrator/reference/AGENTS.md +1 -0
  23. package/.agents/skills/orchestrator/reference/consult-the-orchestrator.md +70 -0
  24. package/.agents/skills/orchestrator/reference/executor-consult-block.md +62 -0
  25. package/.agents/skills/orchestrator/reference/host-detect.md +15 -0
  26. package/.agents/skills/orchestrator/test_orchestrator_skill_contract.py +62 -0
  27. package/.agents/skills/orchestrator-refresh/SKILL.md +16 -34
  28. package/.agents/skills/rebase/AGENTS.md +0 -31
  29. package/.agents/skills/session-log/AGENTS.md +0 -31
  30. package/.agents/skills/session-tidy/AGENTS.md +0 -35
  31. package/.agents/skills/skill-builder/AGENTS.md +0 -48
  32. package/.agents/skills/skill-builder/references/AGENTS.md +0 -24
  33. package/.agents/skills/skill-builder/templates/AGENTS.md +0 -13
  34. package/.agents/skills/skill-builder/workflows/AGENTS.md +0 -18
  35. package/.agents/skills/task-build/AGENTS.md +0 -28
  36. package/.agents/skills/update/AGENTS.md +0 -37
  37. package/.agents/skills-archived/AGENTS.md +0 -44
  38. package/.agents/skills-archived/anthropic-plan/AGENTS.md +0 -33
  39. package/.agents/skills-archived/anthropic-plan/scripts/AGENTS.md +0 -10
  40. package/.agents/skills-archived/anthropic-plan/scripts/anthropic_plan_scripts_constants/AGENTS.md +0 -15
  41. package/.agents/skills-archived/anthropic-plan/templates/AGENTS.md +0 -12
  42. package/.agents/skills-archived/anthropic-plan/workflow/AGENTS.md +0 -13
  43. package/.agents/skills-archived/auditing-claude-config/AGENTS.md +0 -20
  44. package/.agents/skills-archived/bugteam/AGENTS.md +0 -29
  45. package/.agents/skills-archived/bugteam/reference/AGENTS.md +0 -19
  46. package/.agents/skills-archived/bugteam/reference/obstacles/AGENTS.md +0 -23
  47. package/.agents/skills-archived/bugteam/scripts/AGENTS.md +0 -29
  48. package/.agents/skills-archived/bugteam/scripts/bugteam_scripts_constants/AGENTS.md +0 -17
  49. package/.agents/skills-archived/codex-review/AGENTS.md +0 -45
  50. package/.agents/skills-archived/codex-review/reference/AGENTS.md +0 -14
  51. package/.agents/skills-archived/codex-review/scripts/codex_review_scripts_constants/AGENTS.md +0 -17
  52. package/.agents/skills-archived/codex-review/test_skill_scaffold.py +13 -3
  53. package/.agents/skills-archived/copilot-review/AGENTS.md +0 -17
  54. package/.agents/skills-archived/pr-converge/AGENTS.md +0 -31
  55. package/.agents/skills-archived/pr-converge/pr_converge_skill_constants/AGENTS.md +0 -25
  56. package/.agents/skills-archived/pr-converge/reference/AGENTS.md +0 -27
  57. package/.agents/skills-archived/pr-converge/reference/obstacles/AGENTS.md +0 -22
  58. package/.agents/skills-archived/pr-converge/scripts/AGENTS.md +0 -45
  59. package/.agents/skills-archived/pr-converge/scripts/pr_converge_scripts_constants/AGENTS.md +0 -17
  60. package/.agents/skills-archived/pr-converge/workflows/AGENTS.md +0 -15
  61. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  62. package/.agents/skills-archived/recall/AGENTS.md +0 -29
  63. package/.agents/skills-archived/remember/AGENTS.md +0 -30
  64. package/AGENTS.md +0 -112
  65. package/_shared/AGENTS.md +0 -16
  66. package/_shared/advisor/AGENTS.md +0 -21
  67. package/_shared/advisor/advisor-protocol.md +4 -1
  68. package/_shared/advisor/reference/consult-format.md +4 -2
  69. package/_shared/pr-loop/AGENTS.md +0 -27
  70. package/_shared/pr-loop/scripts/AGENTS.md +0 -49
  71. package/_shared/pr-loop/scripts/code_rules_gate_parts/AGENTS.md +0 -41
  72. package/_shared/pr-loop/scripts/codex_review_scripts_constants/AGENTS.md +0 -17
  73. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/AGENTS.md +0 -17
  74. package/_shared/pr-loop/scripts/pr_converge_skill_constants/AGENTS.md +0 -25
  75. package/_shared/pr-loop/scripts/pr_loop_shared_constants/AGENTS.md +0 -25
  76. package/_shared/pr-loop/scripts/tests/AGENTS.md +0 -43
  77. package/_shared/process-tree/AGENTS.md +0 -40
  78. package/audit-rubrics/AGENTS.md +0 -42
  79. package/audit-rubrics/category_rubrics/AGENTS.md +0 -36
  80. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +16 -29
  81. package/audit-rubrics/prompts/AGENTS.md +0 -36
  82. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  83. package/bin/AGENTS.md +0 -118
  84. package/bin/install.test.mjs +1 -1
  85. package/commands/AGENTS.md +0 -14
  86. package/docs/AGENTS.md +0 -31
  87. package/docs/CODE_RULES.md +3 -3
  88. package/docs/agent-spawn-protocol.md +1 -1
  89. package/docs/references/AGENTS.md +0 -16
  90. package/docs/references/prose-style-enforcement.md +5 -9
  91. package/hooks/AGENTS.md +0 -27
  92. package/hooks/advisory/AGENTS.md +0 -15
  93. package/hooks/blocking/AGENTS.md +0 -122
  94. package/hooks/blocking/claude_md_orphan_file_blocker_parts/AGENTS.md +0 -27
  95. package/hooks/blocking/code_rules_docstrings.py +10 -2138
  96. package/hooks/blocking/code_rules_enforcer.py +0 -121
  97. package/hooks/blocking/code_rules_imports_logging.py +1 -236
  98. package/hooks/blocking/code_rules_shared.py +23 -0
  99. package/hooks/blocking/code_rules_test_layout.py +8 -8
  100. package/hooks/blocking/config/AGENTS.md +0 -9
  101. package/hooks/blocking/config/prose_style_enforcement_constants.py +4 -2
  102. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +5 -1
  103. package/hooks/blocking/inventory_intent_records/AGENTS.md +0 -25
  104. package/hooks/blocking/package_inventory_stale_blocker_parts/AGENTS.md +0 -25
  105. package/hooks/blocking/pii_prevention_blocker_parts/AGENTS.md +0 -23
  106. package/hooks/blocking/precommit_code_rules_gate.py +8 -43
  107. package/hooks/blocking/state_description_blocker.py +1 -7
  108. package/hooks/blocking/tdd_enforcer_parts/AGENTS.md +0 -29
  109. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  110. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +0 -8
  111. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +11 -112
  112. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -1
  113. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +1 -18
  114. package/hooks/blocking/test_code_rules_shared.py +12 -0
  115. package/hooks/blocking/test_precommit_code_rules_gate.py +32 -179
  116. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -1
  117. package/hooks/blocking/test_state_description_blocker.py +6 -4
  118. package/hooks/blocking/test_stop_dispatcher.py +5 -29
  119. package/hooks/git-hooks/AGENTS.md +0 -31
  120. package/hooks/git-hooks/git_hooks_constants/AGENTS.md +0 -20
  121. package/hooks/hooks_constants/AGENTS.md +0 -104
  122. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -1
  123. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  124. package/hooks/hooks_constants/messages.py +0 -2
  125. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +3 -17
  126. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -2
  127. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -1
  128. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +7 -0
  129. package/hooks/hooks_constants/test_messages.py +5 -3
  130. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -2
  131. package/hooks/lifecycle/AGENTS.md +0 -17
  132. package/hooks/observability/AGENTS.md +0 -19
  133. package/hooks/session/AGENTS.md +0 -37
  134. package/hooks/validation/AGENTS.md +0 -19
  135. package/hooks/validators/AGENTS.md +0 -51
  136. package/hooks/workflow/AGENTS.md +0 -15
  137. package/output-styles/AGENTS.md +0 -14
  138. package/package.json +1 -1
  139. package/rules/AGENTS.md +0 -58
  140. package/rules/claims-as-quotes.md +0 -10
  141. package/rules/code-standards.md +6 -6
  142. package/rules/explore-thoroughly.md +0 -1
  143. package/rules/failure-blast-radius.md +0 -8
  144. package/rules/falsify-before-green.md +0 -8
  145. package/rules/file-global-constants.md +2 -2
  146. package/rules/filesystem-search.md +1 -1
  147. package/rules/git-workflow.md +1 -9
  148. package/rules/hedging-claims.md +2 -6
  149. package/rules/long-horizon-autonomy.md +1 -1
  150. package/rules/measurement-denominators.md +0 -9
  151. package/rules/research-mode.md +0 -6
  152. package/rules/verify-before-asking.md +0 -5
  153. package/rules/verify-runtime-state.md +0 -5
  154. package/scripts/AGENTS.md +0 -55
  155. package/scripts/codex_compat_materializer.py +0 -12
  156. package/scripts/dev_env_scripts_constants/AGENTS.md +0 -20
  157. package/scripts/sync_to_cursor/AGENTS.md +0 -22
  158. package/scripts/sync_to_cursor/rules.py +0 -10
  159. package/scripts/tests/AGENTS.md +0 -34
  160. package/scripts/tests/test_engine.py +0 -1
  161. package/scripts/tests/test_rules.py +0 -1
  162. package/scripts/tests/test_sync_to_cursor.py +0 -1
  163. package/system-prompts/AGENTS.md +0 -24
  164. package/system-prompts/software-engineer.xml +3 -3
  165. package/hooks/blocking/code_rules_dead_argparse_argument.py +0 -554
  166. package/hooks/blocking/code_rules_dead_config_field.py +0 -568
  167. package/hooks/blocking/code_rules_dead_dataclass_field.py +0 -348
  168. package/hooks/blocking/code_rules_dead_module_constant.py +0 -757
  169. package/hooks/blocking/code_rules_dead_split_branch.py +0 -225
  170. package/hooks/blocking/code_rules_mock_completeness.py +0 -295
  171. package/hooks/blocking/code_rules_scope_binding.py +0 -151
  172. package/hooks/blocking/code_rules_unused_imports.py +0 -197
  173. package/hooks/blocking/hedging_language_blocker.py +0 -221
  174. package/hooks/blocking/intent_only_ending_blocker.py +0 -148
  175. package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +0 -534
  176. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +0 -846
  177. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +0 -507
  178. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +0 -679
  179. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +0 -133
  180. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +0 -103
  181. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +0 -105
  182. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +0 -425
  183. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +0 -176
  184. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +0 -385
  185. package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +0 -398
  186. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +0 -129
  187. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +0 -93
  188. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +0 -198
  189. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +0 -262
  190. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +0 -93
  191. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +0 -115
  192. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +0 -226
  193. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +0 -207
  194. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +0 -262
  195. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +0 -164
  196. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +0 -188
  197. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +0 -157
  198. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +0 -303
  199. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +0 -111
  200. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +0 -656
  201. package/hooks/blocking/test_hedging_language_blocker.py +0 -261
  202. package/hooks/blocking/test_intent_only_ending_blocker.py +0 -209
  203. package/hooks/hooks_constants/dead_argparse_argument_constants.py +0 -28
  204. package/hooks/hooks_constants/dead_config_field_constants.py +0 -39
  205. package/hooks/hooks_constants/dead_dataclass_field_constants.py +0 -25
  206. package/hooks/hooks_constants/dead_module_constant_constants.py +0 -30
  207. package/hooks/hooks_constants/hedging_uncertainty_constants.py +0 -42
  208. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +0 -44
  209. package/rules/conservative-action.md +0 -17
  210. package/rules/context7.md +0 -8
  211. package/rules/few-words.md +0 -3
  212. package/rules/parallel-tools.md +0 -23
@@ -23,107 +23,41 @@ from code_rules_shared import ( # noqa: E402
23
23
  )
24
24
 
25
25
  from hooks_constants.blocking_check_limits import ( # noqa: E402
26
- ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES,
27
26
  ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS,
28
27
  ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES,
29
- ALL_DELEGATION_ENUMERATION_STOP_TOKENS,
30
- ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
31
28
  ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
32
29
  ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
33
- ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES,
34
30
  ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
35
- ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
36
- ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
37
- ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
38
- ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES,
39
31
  ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
40
- ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES,
41
- ALL_DOCSTRING_RUN_MODE_PHRASES,
42
- ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS,
43
32
  ALL_DOCSTRING_RUNON_JOINER_MARKERS,
44
- ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
45
- ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
46
- ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
47
33
  ALL_GENERIC_CHECK_NAME_TOKENS,
48
- ALL_LENGTH_CONSTANT_NAME_SUFFIXES,
49
- ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES,
50
34
  ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
51
- ALL_PATH_METADATA_ACCESS_METHOD_NAMES,
52
- ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES,
53
35
  ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES,
54
- ALL_ZIPFILE_WRITE_MODE_VALUES,
55
- DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
56
- DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME,
57
36
  DOCSTRING_NARRATIVE_LINE_JOIN_SEPARATOR,
58
37
  DOCSTRING_NARRATIVE_PROSE_LINE_LIMIT,
59
38
  DOCSTRING_REFERENCE_MARKER_WINDOW,
60
39
  DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
61
40
  DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
62
41
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
63
- LENGTH_CONFIG_SUBDIRECTORY_NAME,
64
- LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT,
65
42
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
66
- MAX_COMPANION_MODULE_RESOLUTION_DEPTH,
67
43
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
68
- MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
69
- MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
70
- MAX_DOCSTRING_DELEGATION_ENUMERATION_ISSUES,
71
- MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
72
- MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES,
73
44
  MAX_DOCSTRING_FORMAT_ISSUES,
74
- MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
75
- MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES,
76
- MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
77
- MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES,
78
45
  MAX_DOCSTRING_PROSE_WALL_ISSUES,
79
- MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES,
80
- MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
81
46
  MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
82
- MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
83
- MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
84
- MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES,
85
47
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
86
- MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
87
- MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES,
88
48
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
89
49
  MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES,
90
- MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
91
- MINIMUM_DELEGATION_ENUMERATION_ITEMS,
92
- MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
93
- MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
94
- MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
95
50
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
96
51
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
97
- MINIMUM_RANGE_ARGS_FOR_SPAN,
98
52
  MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN,
99
- MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
100
- MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
101
53
  MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
102
- NEIGHBOR_SCAN_FILE_LIMIT,
103
- POINTER_TO_DELEGATE_PATTERN,
104
- PYTHON_MODULE_FILE_SUFFIX,
105
- SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
106
- TOKEN_WORD_PATTERN,
107
- TYPE_CHECKING_IDENTIFIER_MARKER,
108
- WORD_BOUNDARY_REGEX,
109
- ZIPFILE_ALLOW_ZIP64_KEYWORD,
110
- ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX,
111
- ZIPFILE_MODE_KEYWORD,
112
- ZIPFILE_MODE_POSITIONAL_INDEX,
113
- ZIPFILE_WRITER_CLASS_NAME,
114
54
  )
115
55
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
116
56
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
117
- ALL_CARDINAL_NUMBER_WORD_VALUES,
118
57
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
119
58
  ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
120
59
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
121
60
  DOCSTRING_ARG_ENTRY_PATTERN,
122
- DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN,
123
- DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN,
124
- DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
125
- IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
126
- INLINE_CODE_TOKEN_PATTERN,
127
61
  )
128
62
 
129
63
 
@@ -466,158 +400,6 @@ def check_docstring_documents_unreferenced_parameter(
466
400
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
467
401
 
468
402
 
469
- def _callee_expression_name(expression: ast.expr) -> str:
470
- if isinstance(expression, ast.Name):
471
- return expression.id
472
- if isinstance(expression, ast.Attribute):
473
- receiver_name = _callee_expression_name(expression.value)
474
- if not receiver_name:
475
- return ast.unparse(expression)
476
- return f"{receiver_name}.{expression.attr}"
477
- return ""
478
-
479
-
480
- def _call_callee_name(call_node: ast.Call) -> str:
481
- return _callee_expression_name(call_node.func)
482
-
483
-
484
- def _branch_routes_directly_to_call(branch_node: ast.If) -> str:
485
- """Return the callee name an early-return guard routes to, or empty string.
486
-
487
- A guard counts when its block contains exactly one call expression and then
488
- returns. A second call expression disqualifies the block; non-call
489
- statements such as an assignment or a loop are skipped and do not
490
- disqualify it. The await wrapper around an async call is unwrapped first.
491
- """
492
- routed_callee = ""
493
- saw_return = False
494
- for each_statement in branch_node.body:
495
- candidate_expression: ast.expr | None = None
496
- if isinstance(each_statement, ast.Expr):
497
- candidate_expression = each_statement.value
498
- elif isinstance(each_statement, ast.Return):
499
- saw_return = True
500
- continue
501
- if candidate_expression is None:
502
- continue
503
- if isinstance(candidate_expression, ast.Await):
504
- candidate_expression = candidate_expression.value
505
- if not isinstance(candidate_expression, ast.Call):
506
- return ""
507
- if routed_callee:
508
- return ""
509
- routed_callee = _call_callee_name(candidate_expression)
510
- if not saw_return:
511
- return ""
512
- return routed_callee
513
-
514
-
515
- def _shared_fallback_route_count(
516
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
517
- ) -> tuple[str, int]:
518
- route_count_by_callee: dict[str, int] = {}
519
- for each_statement in function_node.body:
520
- if not isinstance(each_statement, ast.If):
521
- continue
522
- routed_callee = _branch_routes_directly_to_call(each_statement)
523
- if not routed_callee:
524
- continue
525
- route_count_by_callee[routed_callee] = (
526
- route_count_by_callee.get(routed_callee, 0) + 1
527
- )
528
- if not route_count_by_callee:
529
- return "", 0
530
- busiest_callee = max(route_count_by_callee, key=lambda name: route_count_by_callee[name])
531
- return busiest_callee, route_count_by_callee[busiest_callee]
532
-
533
-
534
- def _summary_contains_phrase_at_word_boundary(summary_text: str, phrase: str) -> bool:
535
- search_start = 0
536
- while True:
537
- match_index = summary_text.find(phrase, search_start)
538
- if match_index == -1:
539
- return False
540
- preceding_is_boundary = (
541
- match_index == 0 or not summary_text[match_index - 1].isalnum()
542
- )
543
- following_index = match_index + len(phrase)
544
- following_is_boundary = (
545
- following_index >= len(summary_text)
546
- or not summary_text[following_index].isalnum()
547
- )
548
- if preceding_is_boundary and following_is_boundary:
549
- return True
550
- search_start = match_index + 1
551
-
552
-
553
- def _summary_joins_multiple_conditions(summary_text: str) -> bool:
554
- return any(
555
- joining_phrase in summary_text
556
- for joining_phrase in ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES
557
- )
558
-
559
-
560
- def _docstring_summary_scopes_a_single_condition(docstring_text: str) -> bool:
561
- summary_text = docstring_text.split("\n\n", 1)[0].lower()
562
- has_scope_phrase = any(
563
- _summary_contains_phrase_at_word_boundary(summary_text, each_phrase)
564
- for each_phrase in ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES
565
- )
566
- if not has_scope_phrase:
567
- return False
568
- return not _summary_joins_multiple_conditions(summary_text)
569
-
570
-
571
- def check_docstring_fallback_branch_coverage(content: str, file_path: str) -> list[str]:
572
- """Flag a fallback docstring that scopes a branch the body reaches twice.
573
-
574
- The drift this catches: a function whose summary describes a fallback
575
- action under a single condition (``only when``, ``falls back to ... when``)
576
- while the body routes to that same fallback call from two or more distinct
577
- early-return guards. The second guard fires under a condition the prose
578
- never names, so the enumeration the reader trusts is incomplete. This is
579
- the deterministic slice of Category O6 (docstring prose vs implementation
580
- drift): a structural branch-count-versus-prose-condition mismatch.
581
-
582
- Args:
583
- content: The source text to inspect.
584
- file_path: The path the source will be written to, used for exemptions.
585
-
586
- Returns:
587
- One issue per function whose fallback prose omits a second route to the
588
- same call, capped at the module limit.
589
- """
590
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
591
- return []
592
- try:
593
- parsed_tree = ast.parse(content)
594
- except SyntaxError:
595
- return []
596
- issues: list[str] = []
597
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
598
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
599
- continue
600
- if _function_has_exempt_decorator(each_node):
601
- continue
602
- docstring_text = _function_docstring_text(each_node)
603
- if not docstring_text:
604
- continue
605
- if not _docstring_summary_scopes_a_single_condition(docstring_text):
606
- continue
607
- fallback_callee, route_count = _shared_fallback_route_count(each_node)
608
- if route_count < DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT:
609
- continue
610
- issues.append(
611
- f"Line {each_node.lineno}: {each_node.name}() docstring scopes a fallback to "
612
- f"one condition, but the body routes to {fallback_callee}() from {route_count} "
613
- "distinct branches — enumerate every condition that reaches the fallback "
614
- "(Category O6 docstring-vs-implementation drift)"
615
- )
616
- if len(issues) >= MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES:
617
- break
618
- return issues[:MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES]
619
-
620
-
621
403
  def _class_docstring_summary_is_single_line(docstring_text: str) -> bool:
622
404
  stripped_text = docstring_text.strip()
623
405
  if not stripped_text:
@@ -713,405 +495,6 @@ def check_class_docstring_names_public_methods(
713
495
  return issues[:MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES]
714
496
 
715
497
 
716
- def _docstring_claims_no_consumer(docstring_text: str) -> str:
717
- lowered_docstring = docstring_text.lower()
718
- for each_phrase in ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES:
719
- if each_phrase in lowered_docstring:
720
- return each_phrase
721
- return ""
722
-
723
-
724
- def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]:
725
- """Flag a docstring that asserts no consumer reads its produced artifact yet.
726
-
727
- A producer docstring claiming "no consumer reads it yet" (or
728
- "producer-only artifact") is a transitional statement that drifts the moment
729
- a consumer lands. Once a submission run, gate, or any reader loads the
730
- artifact, the claim contradicts both the live behavior and any companion
731
- SKILL.md that documents the consumer — the Category O8 docstring /
732
- companion-doc producer-consumer drift. The claim is also a no-historical /
733
- no-transitional-language violation in its own right: a docstring describes
734
- the contract that exists, not a not-yet-wired future. Rephrase to state what
735
- reads the artifact, or drop the no-consumer sentence entirely.
736
-
737
- Args:
738
- content: The source text to inspect.
739
- file_path: The path the source will be written to, used for exemptions.
740
-
741
- Returns:
742
- One issue per function whose docstring claims no consumer reads its
743
- output, capped at the module limit.
744
- """
745
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
746
- return []
747
- try:
748
- parsed_tree = ast.parse(content)
749
- except SyntaxError:
750
- return []
751
- issues: list[str] = []
752
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
753
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
754
- continue
755
- if _function_has_exempt_decorator(each_node):
756
- continue
757
- docstring_text = _function_docstring_text(each_node)
758
- if not docstring_text:
759
- continue
760
- matched_phrase = _docstring_claims_no_consumer(docstring_text)
761
- if not matched_phrase:
762
- continue
763
- issues.append(
764
- f"Line {each_node.lineno}: {each_node.name}() docstring claims "
765
- f"'{matched_phrase}' — a no-consumer-yet claim drifts the moment a reader "
766
- "lands and contradicts any companion SKILL.md; state what reads the artifact "
767
- "or drop the sentence (Category O8 docstring / companion-doc drift)"
768
- )
769
- if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
770
- break
771
- return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
772
-
773
-
774
- def _docstring_claims_malformed_payload_is_guarded(docstring_text: str) -> str:
775
- collapsed_docstring = " ".join(docstring_text.lower().split())
776
- for each_phrase in ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES:
777
- if each_phrase in collapsed_docstring:
778
- return each_phrase
779
- return ""
780
-
781
-
782
- def _try_handler_returns_none(try_node: ast.Try) -> bool:
783
- for each_handler in try_node.handlers:
784
- for each_statement in each_handler.body:
785
- if isinstance(each_statement, ast.Return) and isinstance(
786
- each_statement.value, ast.Constant
787
- ):
788
- if each_statement.value.value is None:
789
- return True
790
- return False
791
-
792
-
793
- def _names_bound_in_try_body(try_node: ast.Try) -> set[str]:
794
- all_bound_names: set[str] = set()
795
- for each_statement in try_node.body:
796
- for each_descendant in ast.walk(each_statement):
797
- if isinstance(each_descendant, ast.Name) and isinstance(
798
- each_descendant.ctx, ast.Store
799
- ):
800
- all_bound_names.add(each_descendant.id)
801
- return all_bound_names
802
-
803
-
804
- def _statement_subscripts_one_of(
805
- statement: ast.stmt, all_payload_names: set[str]
806
- ) -> bool:
807
- for each_descendant in ast.walk(statement):
808
- if (
809
- isinstance(each_descendant, ast.Subscript)
810
- and isinstance(each_descendant.value, ast.Name)
811
- and each_descendant.value.id in all_payload_names
812
- ):
813
- return True
814
- return False
815
-
816
-
817
- def _function_has_unguarded_payload_dereference(
818
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
819
- ) -> bool:
820
- all_payload_names: set[str] = set()
821
- saw_returning_guard = False
822
- for each_statement in function_node.body:
823
- if isinstance(each_statement, ast.Try) and _try_handler_returns_none(
824
- each_statement
825
- ):
826
- all_payload_names |= _names_bound_in_try_body(each_statement)
827
- saw_returning_guard = True
828
- continue
829
- if not saw_returning_guard:
830
- continue
831
- if _statement_subscripts_one_of(each_statement, all_payload_names):
832
- return True
833
- return False
834
-
835
-
836
- def check_docstring_unguarded_malformed_payload_claim(
837
- content: str, file_path: str
838
- ) -> list[str]:
839
- """Flag a docstring that promises malformed-payload safety the guard misses.
840
-
841
- The drift this catches: a function whose docstring states that a malformed
842
- payload "resolves to None" while a subscript dereference of that payload
843
- (``payload["key"]``, ``float(payload["key"])``) sits OUTSIDE the try/except
844
- whose handler returns None. A present-but-malformed payload then raises
845
- KeyError or TypeError from that unguarded access and propagates rather than
846
- resolving to None, so the docstring overstates the protection. This is the
847
- deterministic slice of Category O6 (docstring prose vs implementation drift)
848
- for an exception-guard claim: move the dereference inside the guarded block,
849
- or narrow the docstring to the failures the guard actually catches.
850
-
851
- Args:
852
- content: The source text to inspect.
853
- file_path: The path the source will be written to, used for exemptions.
854
-
855
- Returns:
856
- One issue per function whose malformed-payload claim outruns its guard,
857
- capped at the module limit.
858
- """
859
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
860
- return []
861
- try:
862
- parsed_tree = ast.parse(content)
863
- except SyntaxError:
864
- return []
865
- issues: list[str] = []
866
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
867
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
868
- continue
869
- if _function_has_exempt_decorator(each_node):
870
- continue
871
- docstring_text = _function_docstring_text(each_node)
872
- if not docstring_text:
873
- continue
874
- matched_phrase = _docstring_claims_malformed_payload_is_guarded(docstring_text)
875
- if not matched_phrase:
876
- continue
877
- if not _function_has_unguarded_payload_dereference(each_node):
878
- continue
879
- issues.append(
880
- f"Line {each_node.lineno}: {each_node.name}() docstring claims "
881
- f"'{matched_phrase}' but a payload subscript sits outside the try/except that "
882
- "returns None — a malformed-but-present payload raises rather than resolving to "
883
- "None; move the dereference inside the guard or narrow the docstring "
884
- "(Category O6 docstring-vs-implementation drift)"
885
- )
886
- if len(issues) >= MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES:
887
- break
888
- return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
889
-
890
-
891
- def _docstring_claims_no_network_access(docstring_text: str) -> str:
892
- collapsed_docstring = " ".join(docstring_text.lower().split())
893
- for each_phrase in ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES:
894
- if each_phrase in collapsed_docstring:
895
- return each_phrase
896
- return ""
897
-
898
-
899
- def _function_performs_path_metadata_access(
900
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
901
- ) -> str:
902
- for each_descendant in ast.walk(function_node):
903
- if not isinstance(each_descendant, ast.Call):
904
- continue
905
- callee = each_descendant.func
906
- if (
907
- isinstance(callee, ast.Attribute)
908
- and callee.attr in ALL_PATH_METADATA_ACCESS_METHOD_NAMES
909
- ):
910
- return callee.attr
911
- return ""
912
-
913
-
914
- def check_docstring_no_network_claim_with_metadata_access(
915
- content: str, file_path: str
916
- ) -> list[str]:
917
- """Flag a docstring promising no network touch while the body stats a path.
918
-
919
- Picture a cache helper whose docstring says it returns the warm cache
920
- "without touching the network." A reader trusts that a cache hit costs
921
- nothing on the share. But the body calls ``share_path.is_file()`` and
922
- ``share_path.stat().st_size`` before it can return the cache — and on a
923
- network share each of those metadata calls is a round-trip over the wire.
924
- The promise drifts: every cache hit still pays two network stats the
925
- docstring swore it avoided.
926
-
927
- This is the deterministic slice of Category O6 (docstring prose versus
928
- implementation drift) for a no-network claim: a function docstring that
929
- states the path returns ``without touching the network`` (or a sibling
930
- no-network phrase) while the body calls ``is_file``, ``is_dir``,
931
- ``exists``, ``stat``, or ``lstat``. Either reword the claim to state that
932
- the path is stat-checked on every call, or short-circuit to the cached
933
- path before the share is touched.
934
-
935
- Args:
936
- content: The source text to inspect.
937
- file_path: The path the source will be written to, used for exemptions.
938
-
939
- Returns:
940
- One issue per function whose no-network claim coexists with a
941
- path-metadata call, capped at the module limit.
942
- """
943
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
944
- return []
945
- try:
946
- parsed_tree = ast.parse(content)
947
- except SyntaxError:
948
- return []
949
- issues: list[str] = []
950
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
951
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
952
- continue
953
- if _function_has_exempt_decorator(each_node):
954
- continue
955
- docstring_text = _function_docstring_text(each_node)
956
- if not docstring_text:
957
- continue
958
- matched_phrase = _docstring_claims_no_network_access(docstring_text)
959
- if not matched_phrase:
960
- continue
961
- accessed_method = _function_performs_path_metadata_access(each_node)
962
- if not accessed_method:
963
- continue
964
- issues.append(
965
- f"Line {each_node.lineno}: {each_node.name}() docstring claims "
966
- f"'{matched_phrase}' but the body calls .{accessed_method}() on a path — "
967
- "a metadata stat is itself a network touch on a share, so the no-network "
968
- "claim drifts; reword the docstring to state the path is stat-checked on "
969
- "every call, or short-circuit before touching the share "
970
- "(Category O6 docstring-vs-implementation drift)"
971
- )
972
- if len(issues) >= MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES:
973
- break
974
- return issues[:MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES]
975
-
976
-
977
- def _module_code_references_type_checking(parsed_tree: ast.Module) -> bool:
978
- """Return True when a code identifier in the module handles TYPE_CHECKING.
979
-
980
- A module that names TYPE_CHECKING in code genuinely gates on it. That covers
981
- a Name reference (any context — load, store, or delete), an import alias, an
982
- attribute access, or a function or class definition whose name carries the
983
- type_checking marker. Docstring text is an ast.Constant and never counts, so
984
- the check can still flag a gate the prose names while the body performs none.
985
- """
986
- for each_node in ast.walk(parsed_tree):
987
- if isinstance(each_node, ast.Name) and (
988
- TYPE_CHECKING_IDENTIFIER_MARKER in each_node.id.lower()
989
- ):
990
- return True
991
- if isinstance(each_node, ast.Attribute) and (
992
- TYPE_CHECKING_IDENTIFIER_MARKER in each_node.attr.lower()
993
- ):
994
- return True
995
- if isinstance(each_node, ast.alias) and any(
996
- each_alias_name and TYPE_CHECKING_IDENTIFIER_MARKER in each_alias_name.lower()
997
- for each_alias_name in (each_node.name, each_node.asname)
998
- ):
999
- return True
1000
- if isinstance(
1001
- each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
1002
- ) and TYPE_CHECKING_IDENTIFIER_MARKER in each_node.name.lower():
1003
- return True
1004
- return False
1005
-
1006
-
1007
- def _docstring_names_absent_type_checking_gate(docstring_text: str) -> str:
1008
- collapsed_docstring = " ".join(docstring_text.replace("`", " ").lower().split())
1009
- for each_phrase in ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES:
1010
- if each_phrase in collapsed_docstring:
1011
- return each_phrase
1012
- return ""
1013
-
1014
-
1015
- def check_docstring_names_absent_type_checking_gate(
1016
- content: str, file_path: str
1017
- ) -> list[str]:
1018
- """Flag a docstring naming a TYPE_CHECKING gate the module's code never runs.
1019
-
1020
- Picture a hook whose docstring advertises a `TYPE_CHECKING` gate-detection
1021
- step, or a module summary that names `type-checking-gate` helpers, while no
1022
- identifier in the body handles TYPE_CHECKING. A reader trusts the prose and
1023
- looks for the gate, but the code performs none, so the claim points at
1024
- machinery the module does not hold.
1025
-
1026
- This is the deterministic slice of Category O6 docstring-prose-vs-
1027
- implementation drift for a TYPE_CHECKING gate claim. The check reads the
1028
- module docstring and every function and class docstring. It fires only when
1029
- a docstring names a `TYPE_CHECKING` gate or a `type-checking-gate` helper
1030
- family while no code identifier in the module carries the `type_checking`
1031
- marker, so a module that genuinely gates on TYPE_CHECKING is left alone.
1032
- Hook infrastructure is in scope, since the import-scan hooks that carry this
1033
- drift class are themselves hooks. Test files are exempt.
1034
-
1035
- Args:
1036
- content: The source text to inspect.
1037
- file_path: The path the source will be written to, used for exemptions.
1038
-
1039
- Returns:
1040
- One issue per docstring naming a TYPE_CHECKING gate the code never
1041
- performs, capped at the module limit.
1042
- """
1043
- if is_strict_test_file(file_path):
1044
- return []
1045
- try:
1046
- parsed_tree = ast.parse(content)
1047
- except SyntaxError:
1048
- return []
1049
- if _module_code_references_type_checking(parsed_tree):
1050
- return []
1051
- issues: list[str] = []
1052
- for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
1053
- matched_phrase = _docstring_names_absent_type_checking_gate(each_docstring)
1054
- if not matched_phrase:
1055
- continue
1056
- issues.append(
1057
- f"Line {each_line_number}: docstring names a '{matched_phrase}' the module's "
1058
- "code never performs — no identifier in the body handles TYPE_CHECKING, so the "
1059
- "gate-detection claim is stale; drop the TYPE_CHECKING gate wording or add the "
1060
- "detection (Category O6 docstring-vs-implementation drift)"
1061
- )
1062
- if len(issues) >= MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES:
1063
- break
1064
- return issues[:MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES]
1065
-
1066
-
1067
- def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
1068
- collapsed_docstring = " ".join(module_docstring.lower().split())
1069
- for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
1070
- if each_phrase in collapsed_docstring:
1071
- return each_phrase
1072
- return ""
1073
-
1074
-
1075
- def check_docstring_no_inline_literal_claim(content: str, file_path: str) -> list[str]:
1076
- """Flag a module docstring that asserts no literals appear inline elsewhere.
1077
-
1078
- A constants-module docstring claiming "no literals appear inline in the
1079
- dispatcher" makes an unverifiable completeness claim about a companion file.
1080
- The claim drifts the moment a literal lands inline in that companion — a deny
1081
- or block reason left inline in the dispatcher contradicts the docstring even
1082
- though the constants file under edit never changed. This is the deterministic
1083
- slice of Category O6 (docstring prose vs implementation drift) and a
1084
- no-transitional-language violation in its own right: a docstring describes
1085
- what the module holds, not the absence of literals in a sibling. Rephrase to
1086
- state what the module centralizes, or drop the no-inline-literal sentence.
1087
-
1088
- Args:
1089
- content: The source text to inspect.
1090
- file_path: The path the source will be written to, used for exemptions.
1091
-
1092
- Returns:
1093
- One issue when the module docstring carries a no-inline-literal claim,
1094
- capped at the module limit.
1095
- """
1096
- if is_strict_test_file(file_path):
1097
- return []
1098
- try:
1099
- parsed_tree = ast.parse(content)
1100
- except SyntaxError:
1101
- return []
1102
- module_docstring = ast.get_docstring(parsed_tree) or ""
1103
- matched_phrase = _module_docstring_claims_no_inline_literal(module_docstring)
1104
- if not matched_phrase:
1105
- return []
1106
- issues = [
1107
- f"Line 1: module docstring claims '{matched_phrase}' about a companion file "
1108
- "— an unverifiable completeness claim that drifts the moment a literal lands "
1109
- "inline; state what the module centralizes instead (Category O6 docstring-vs-"
1110
- "implementation drift)"
1111
- ]
1112
- return issues[:MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES]
1113
-
1114
-
1115
498
  def _module_docstring_summary_is_single_paragraph(module_docstring: str) -> bool:
1116
499
  stripped_text = module_docstring.strip()
1117
500
  if not stripped_text:
@@ -1342,896 +725,16 @@ def check_module_docstring_scope_omits_data_schema_constants(
1342
725
  ][:MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES]
1343
726
 
1344
727
 
1345
- def _module_string_tuple_members(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
1346
- members_by_constant: dict[str, frozenset[str]] = {}
1347
- for each_statement in parsed_tree.body:
1348
- if not isinstance(each_statement, ast.Assign):
1349
- continue
1350
- if not isinstance(each_statement.value, ast.Tuple):
1351
- continue
1352
- literal_members: set[str] = set()
1353
- every_member_is_identifier_shaped = True
1354
- for each_element in each_statement.value.elts:
1355
- if (
1356
- isinstance(each_element, ast.Constant)
1357
- and isinstance(each_element.value, str)
1358
- and IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN.match(each_element.value)
1359
- ):
1360
- literal_members.add(each_element.value.lstrip("."))
1361
- continue
1362
- every_member_is_identifier_shaped = False
1363
- break
1364
- if not every_member_is_identifier_shaped:
1365
- continue
1366
- if len(literal_members) < MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION:
1367
- continue
1368
- for each_target in each_statement.targets:
1369
- if isinstance(each_target, ast.Name):
1370
- members_by_constant[each_target.id] = frozenset(literal_members)
1371
- return members_by_constant
1372
-
1373
-
1374
- def _names_referenced_in_function(
1375
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1376
- ) -> set[str]:
1377
- return {
1378
- each_node.id
1379
- for each_node in ast.walk(function_node)
1380
- if isinstance(each_node, ast.Name)
1381
- }
1382
-
1383
-
1384
- def _docstring_inline_code_tokens(docstring_text: str) -> set[str]:
1385
- tokens: set[str] = set()
1386
- for each_match in INLINE_CODE_TOKEN_PATTERN.finditer(docstring_text):
1387
- token = each_match.group(1).strip().lstrip(".")
1388
- if token:
1389
- tokens.add(token)
1390
- return tokens
1391
-
1392
-
1393
- def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> list[str]:
1394
- """Flag a docstring enumeration that drifts from a literal tuple it reads.
1395
-
1396
- The drift this catches: a function reads a module-level tuple of literal
1397
- string members and its docstring enumerates inline-code tokens that name
1398
- some of those members, but the enumerated set and the tuple membership
1399
- differ. A token the docstring lists that the tuple lacks, or a tuple member
1400
- the docstring omits, misleads a reader who trusts the prose enumeration to
1401
- match the detection set — the deterministic slice of Category O6
1402
- docstring-prose-vs-implementation drift. The check binds only when the
1403
- docstring's inline-code tokens overlap the tuple membership, so a docstring
1404
- that names unrelated attributes is left alone. This check covers hook
1405
- infrastructure, where the affected detection tuples live.
1406
-
1407
- Args:
1408
- content: The source text to inspect.
1409
- file_path: The path the source will be written to, used for exemptions.
1410
-
1411
- Returns:
1412
- One issue per function whose docstring enumeration diverges from the
1413
- tuple it reads, capped at the module limit.
1414
- """
1415
- if is_strict_test_file(file_path):
1416
- return []
1417
- try:
1418
- parsed_tree = ast.parse(content)
1419
- except SyntaxError:
1420
- return []
1421
- members_by_constant = _module_string_tuple_members(parsed_tree)
1422
- if not members_by_constant:
1423
- return []
1424
- issues: list[str] = []
1425
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1426
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1427
- continue
1428
- docstring_text = _function_docstring_text(each_node)
1429
- if not docstring_text:
1430
- continue
1431
- docstring_tokens = _docstring_inline_code_tokens(docstring_text)
1432
- if not docstring_tokens:
1433
- continue
1434
- referenced_names = _names_referenced_in_function(each_node)
1435
- for each_constant_name in referenced_names & set(members_by_constant):
1436
- tuple_members = members_by_constant[each_constant_name]
1437
- if not (docstring_tokens & tuple_members):
1438
- continue
1439
- if docstring_tokens == tuple_members:
1440
- continue
1441
- docstring_only = sorted(docstring_tokens - tuple_members)
1442
- tuple_only = sorted(tuple_members - docstring_tokens)
1443
- issues.append(
1444
- f"Line {each_node.lineno}: {each_node.name}() docstring enumerates "
1445
- f"{sorted(docstring_tokens)} but {each_constant_name} holds "
1446
- f"{sorted(tuple_members)} — docstring-only: {docstring_only}, "
1447
- f"tuple-only: {tuple_only}; match the enumeration to the tuple "
1448
- "(Category O6 docstring-vs-implementation drift)"
1449
- )
1450
- if len(issues) >= MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES:
1451
- return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
1452
- return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
1453
-
1454
-
1455
- def _known_glyph_marker_members(
1456
- sequence_node: ast.Tuple | ast.List,
1457
- ) -> frozenset[str] | None:
1458
- normalized_glyphs: set[str] = set()
1459
- for each_element in sequence_node.elts:
1460
- if not (
1461
- isinstance(each_element, ast.Constant)
1462
- and isinstance(each_element.value, str)
1463
- ):
1464
- return None
1465
- normalized_glyph = each_element.value.strip()
1466
- if normalized_glyph not in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES:
1467
- return None
1468
- normalized_glyphs.add(normalized_glyph)
1469
- return frozenset(normalized_glyphs)
1470
-
1471
-
1472
- def _assignment_targets_and_sequence(
1473
- statement: ast.stmt,
1474
- ) -> tuple[list[str], ast.Tuple | ast.List | None]:
1475
- if isinstance(statement, ast.Assign) and isinstance(
1476
- statement.value, (ast.Tuple, ast.List)
1477
- ):
1478
- plain_target_names = [
1479
- each_target.id
1480
- for each_target in statement.targets
1481
- if isinstance(each_target, ast.Name)
1482
- ]
1483
- return plain_target_names, statement.value
1484
- if (
1485
- isinstance(statement, ast.AnnAssign)
1486
- and isinstance(statement.target, ast.Name)
1487
- and isinstance(statement.value, (ast.Tuple, ast.List))
1488
- ):
1489
- return [statement.target.id], statement.value
1490
- return [], None
1491
-
1492
-
1493
- def _module_glyph_marker_tuples(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
1494
- glyphs_by_constant: dict[str, frozenset[str]] = {}
1495
- for each_statement in parsed_tree.body:
1496
- target_names, sequence_node = _assignment_targets_and_sequence(each_statement)
1497
- if sequence_node is None:
1498
- continue
1499
- marker_glyphs = _known_glyph_marker_members(sequence_node)
1500
- if marker_glyphs is None:
1501
- continue
1502
- if len(marker_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
1503
- continue
1504
- for each_target_name in target_names:
1505
- glyphs_by_constant[each_target_name] = marker_glyphs
1506
- return glyphs_by_constant
1507
-
1508
-
1509
- def _companion_module_file(dotted_module: str, file_path: str) -> Path | None:
1510
- relative_module_path = Path(*dotted_module.split(".")).with_suffix(
1511
- PYTHON_MODULE_FILE_SUFFIX
1512
- )
1513
- file_directory = Path(file_path).parent
1514
- candidate_roots = [file_directory, *file_directory.parents]
1515
- for each_root in candidate_roots[:MAX_COMPANION_MODULE_RESOLUTION_DEPTH]:
1516
- candidate_path = each_root / relative_module_path
1517
- if candidate_path.is_file():
1518
- return candidate_path
1519
- return None
1520
-
1521
-
1522
- def _companion_glyph_marker_tuples(companion_path: Path) -> dict[str, frozenset[str]]:
1523
- try:
1524
- companion_source = companion_path.read_text(encoding="utf-8")
1525
- companion_tree = ast.parse(companion_source)
1526
- except (OSError, ValueError, SyntaxError):
1527
- return {}
1528
- return _module_glyph_marker_tuples(companion_tree)
1529
-
1530
-
1531
- def _import_brings_upper_snake_name(import_node: ast.ImportFrom) -> bool:
1532
- return any(
1533
- ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_alias.name)
1534
- for each_alias in import_node.names
1535
- )
1536
-
1537
-
1538
- def _imported_glyph_marker_tuples(
1539
- parsed_tree: ast.Module, file_path: str
1540
- ) -> dict[str, frozenset[str]]:
1541
- glyphs_by_imported_name: dict[str, frozenset[str]] = {}
1542
- for each_statement in parsed_tree.body:
1543
- if not isinstance(each_statement, ast.ImportFrom):
1544
- continue
1545
- if not each_statement.module:
1546
- continue
1547
- if not _import_brings_upper_snake_name(each_statement):
1548
- continue
1549
- companion_path = _companion_module_file(each_statement.module, file_path)
1550
- if companion_path is None:
1551
- continue
1552
- companion_tuples = _companion_glyph_marker_tuples(companion_path)
1553
- for each_alias in each_statement.names:
1554
- if each_alias.name not in companion_tuples:
1555
- continue
1556
- imported_name = each_alias.asname or each_alias.name
1557
- glyphs_by_imported_name[imported_name] = companion_tuples[each_alias.name]
1558
- return glyphs_by_imported_name
1559
-
1560
-
1561
- def _docstring_names_mark_glyph(docstring_text: str, normalized_glyph: str) -> bool:
1562
- lowercased_docstring = docstring_text.lower()
1563
- for each_name in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES[normalized_glyph]:
1564
- boundary_wrapped_name = (
1565
- WORD_BOUNDARY_REGEX + re.escape(each_name) + WORD_BOUNDARY_REGEX
1566
- )
1567
- if re.search(boundary_wrapped_name, lowercased_docstring):
1568
- return True
1569
- return False
1570
-
1571
-
1572
- def _marks_named_in_docstring(
1573
- docstring_text: str, all_marker_glyphs: frozenset[str]
1574
- ) -> set[str]:
1575
- return {
1576
- each_glyph
1577
- for each_glyph in all_marker_glyphs
1578
- if _docstring_names_mark_glyph(docstring_text, each_glyph)
1579
- }
1580
-
1581
-
1582
- def _text_names_multiple_marks(content_text: str) -> bool:
1583
- all_known_glyphs = frozenset(ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES)
1584
- named_glyphs = _marks_named_in_docstring(content_text, all_known_glyphs)
1585
- return len(named_glyphs) >= MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION
1586
-
1587
-
1588
- def check_docstring_punctuation_mark_enumeration_coverage(
1589
- content: str, file_path: str
1590
- ) -> list[str]:
1591
- """Flag a docstring that names some marks of a glyph tuple but omits one.
1592
-
1593
- A module reads a tuple of punctuation-mark glyphs as its detection set. The
1594
- tuple is defined in the module or imported from a companion module beside it.
1595
- A docstring then enumerates those marks by their English names. When the
1596
- prose names a closed set of marks but leaves one the tuple holds unnamed, a
1597
- reader trusts the enumeration and believes an active mark never triggers the
1598
- check. This is the shape that appears when a glyph joins the tuple while the
1599
- prose enumeration stays as it was.
1600
-
1601
- The check binds only when a docstring names two or more marks of one tuple. A
1602
- docstring that mentions a single mark, names every mark, or describes
1603
- unrelated punctuation is left alone. This is the deterministic glyph-prose
1604
- slice of Category O6 docstring-prose-vs-implementation drift, the companion
1605
- to check_docstring_tuple_enumeration_match for glyph members named in prose
1606
- rather than identifier members named in inline code. It covers hook
1607
- infrastructure, where the affected detection tuples live.
1608
-
1609
- Args:
1610
- content: The source text to inspect.
1611
- file_path: The path the source will be written to, used for exemptions.
1612
-
1613
- Returns:
1614
- One issue per docstring whose mark enumeration omits a glyph the tuple it
1615
- describes holds, capped at the module limit.
1616
- """
1617
- if is_strict_test_file(file_path):
1618
- return []
1619
- if not _text_names_multiple_marks(content):
1620
- return []
1621
- try:
1622
- parsed_tree = ast.parse(content)
1623
- except SyntaxError:
1624
- return []
1625
- glyphs_by_constant = {
1626
- **_module_glyph_marker_tuples(parsed_tree),
1627
- **_imported_glyph_marker_tuples(parsed_tree, file_path),
1628
- }
1629
- if not glyphs_by_constant:
1630
- return []
1631
- issues: list[str] = []
1632
- for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
1633
- for each_constant_name in sorted(glyphs_by_constant):
1634
- marker_glyphs = glyphs_by_constant[each_constant_name]
1635
- named_glyphs = _marks_named_in_docstring(each_docstring, marker_glyphs)
1636
- if len(named_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
1637
- continue
1638
- omitted_glyphs = marker_glyphs - named_glyphs
1639
- if not omitted_glyphs:
1640
- continue
1641
- issues.append(
1642
- f"Line {each_line}: docstring names {sorted(named_glyphs)} from "
1643
- f"{each_constant_name} but omits {sorted(omitted_glyphs)} — name every "
1644
- "mark the tuple holds so the enumeration matches the detection set "
1645
- "(Category O6 docstring-vs-implementation drift)"
1646
- )
1647
- if len(issues) >= MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES:
1648
- return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
1649
- return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
1650
-
1651
-
1652
- def _returns_section_text(docstring_text: str) -> str:
1653
- docstring_lines = docstring_text.splitlines()
1654
- returns_section_lines: list[str] = []
1655
- inside_returns_section = False
1656
- for each_line in docstring_lines:
1657
- stripped_line = each_line.strip()
1658
- if stripped_line in ("Returns:", "Yields:"):
1659
- inside_returns_section = True
1660
- continue
1661
- if not inside_returns_section:
1662
- continue
1663
- if _is_docstring_terminating_section_header(stripped_line):
1664
- break
1665
- returns_section_lines.append(stripped_line)
1666
- return " ".join(returns_section_lines)
1667
-
1668
-
1669
- def _plural_families_in_returns_section(returns_section_text: str) -> set[str]:
1670
- return {
1671
- each_match.group(1)
1672
- for each_match in DOCSTRING_PLURAL_FAMILY_STOP_PATTERN.finditer(returns_section_text)
1673
- }
1674
-
1675
-
1676
- def _returned_dict_key_names(
1677
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1678
- ) -> set[str]:
1679
- all_key_names: set[str] = set()
1680
- for each_node in ast.walk(function_node):
1681
- if not isinstance(each_node, ast.Return):
1682
- continue
1683
- if not isinstance(each_node.value, ast.Dict):
1684
- continue
1685
- for each_key in each_node.value.keys:
1686
- if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
1687
- all_key_names.add(each_key.value)
1688
- return all_key_names
1689
-
1690
-
1691
- def _family_prefixed_key_count(family: str, all_key_names: set[str]) -> int:
1692
- family_prefix = f"{family}_"
1693
- return sum(1 for each_key in all_key_names if each_key.startswith(family_prefix))
1694
-
1695
-
1696
- def check_docstring_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
1697
- """Flag a Returns clause plural noun that names one dict key in its family.
1698
-
1699
- The drift this catches: a function returns a dict literal whose keys carry
1700
- prefix families (``sheen_mid``, ``body_highlight``), and its Returns clause
1701
- names one family with a plural noun (``the sheen stops``) while exactly one
1702
- key in that family exists. The plural prose claims two or more entries the
1703
- dict no longer holds — the shape that appears when a producer removes the
1704
- second key in a family but leaves the plural prose untouched. The check binds
1705
- only when the plural family prefixes exactly one returned dict key, so a
1706
- singular noun, a family with two or more keys, and a family absent from the
1707
- dict are all left alone. This is the deterministic single-key slice of
1708
- Category O6 docstring-prose-vs-implementation drift.
1709
-
1710
- Args:
1711
- content: The source text to inspect.
1712
- file_path: The path the source will be written to, used for exemptions.
1713
-
1714
- Returns:
1715
- One issue per function whose Returns clause names a plural family that
1716
- prefixes a single returned dict key, capped at the module limit.
1717
- """
1718
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
1719
- return []
1720
- try:
1721
- parsed_tree = ast.parse(content)
1722
- except SyntaxError:
1723
- return []
1724
- issues: list[str] = []
1725
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1726
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1727
- continue
1728
- docstring_text = _function_docstring_text(each_node)
1729
- if not docstring_text:
1730
- continue
1731
- returns_section_text = _returns_section_text(docstring_text)
1732
- if not returns_section_text:
1733
- continue
1734
- plural_families = _plural_families_in_returns_section(returns_section_text)
1735
- if not plural_families:
1736
- continue
1737
- all_key_names = _returned_dict_key_names(each_node)
1738
- for each_family in sorted(plural_families):
1739
- matching_key_count = _family_prefixed_key_count(each_family, all_key_names)
1740
- if matching_key_count != SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT:
1741
- continue
1742
- issues.append(
1743
- f"Line {each_node.lineno}: {each_node.name}() Returns clause says "
1744
- f"'the {each_family} stops' (plural) but the returned dict holds a "
1745
- f"single {each_family}_ key — match the noun to the cardinality "
1746
- "(Category O6 docstring-vs-implementation drift)"
1747
- )
1748
- if len(issues) >= MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES:
1749
- return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1750
- return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1751
-
1752
-
1753
- def _module_level_length_constant_names(parsed_tree: ast.Module) -> set[str]:
1754
- all_length_constant_names: set[str] = set()
1755
- for each_statement in parsed_tree.body:
1756
- target_names: list[str] = []
1757
- assigned_value: ast.expr | None = None
1758
- if isinstance(each_statement, ast.Assign):
1759
- assigned_value = each_statement.value
1760
- target_names = [
1761
- each_target.id
1762
- for each_target in each_statement.targets
1763
- if isinstance(each_target, ast.Name)
1764
- ]
1765
- elif isinstance(each_statement, ast.AnnAssign) and isinstance(
1766
- each_statement.target, ast.Name
1767
- ):
1768
- assigned_value = each_statement.value
1769
- target_names = [each_statement.target.id]
1770
- if not isinstance(assigned_value, ast.Constant) or not isinstance(
1771
- assigned_value.value, int
1772
- ):
1773
- continue
1774
- for each_name in target_names:
1775
- if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_name):
1776
- continue
1777
- if each_name.endswith(ALL_LENGTH_CONSTANT_NAME_SUFFIXES):
1778
- all_length_constant_names.add(each_name)
1779
- return all_length_constant_names
1780
-
1781
-
1782
- def _superlative_phrase_by_length_constant(
1783
- parsed_tree: ast.Module, all_length_constant_names: set[str]
1784
- ) -> tuple[dict[str, str], str]:
1785
- module_docstring = ast.get_docstring(parsed_tree) or ""
1786
- phrase_by_constant: dict[str, str] = {}
1787
- unattributed_phrase = ""
1788
- for each_sentence in DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN.split(module_docstring):
1789
- lowered_sentence = each_sentence.lower()
1790
- sentence_phrase = next(
1791
- (
1792
- each_phrase
1793
- for each_phrase in ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES
1794
- if each_phrase in lowered_sentence
1795
- ),
1796
- "",
1797
- )
1798
- if not sentence_phrase:
1799
- continue
1800
- named_constants = {
1801
- each_name
1802
- for each_name in all_length_constant_names
1803
- if each_name in each_sentence
1804
- }
1805
- if named_constants:
1806
- for each_name in named_constants:
1807
- phrase_by_constant.setdefault(each_name, sentence_phrase)
1808
- elif not unattributed_phrase:
1809
- unattributed_phrase = sentence_phrase
1810
- return phrase_by_constant, unattributed_phrase
1811
-
1812
-
1813
- def _compare_targets_a_length_constant(
1814
- compare_node: ast.Compare, all_length_constant_names: set[str]
1815
- ) -> str:
1816
- operands = [compare_node.left, *compare_node.comparators]
1817
- has_length_call = any(
1818
- isinstance(each_operand, ast.Call)
1819
- and isinstance(each_operand.func, ast.Name)
1820
- and each_operand.func.id == "len"
1821
- for each_operand in operands
1822
- )
1823
- if not has_length_call:
1824
- return ""
1825
- for each_operand in operands:
1826
- if isinstance(each_operand, ast.Name) and each_operand.id in all_length_constant_names:
1827
- return each_operand.id
1828
- return ""
1829
-
1830
-
1831
- def _length_gate_comparison_kinds_in_tree(
1832
- parsed_tree: ast.Module, all_length_constant_names: set[str]
1833
- ) -> dict[str, set[str]]:
1834
- comparison_kinds_by_constant: dict[str, set[str]] = {}
1835
- for each_node in ast.walk(parsed_tree):
1836
- if not isinstance(each_node, ast.Compare):
1837
- continue
1838
- compared_constant = _compare_targets_a_length_constant(
1839
- each_node, all_length_constant_names
1840
- )
1841
- if not compared_constant:
1842
- continue
1843
- for each_operator in each_node.ops:
1844
- kind = (
1845
- "equality"
1846
- if isinstance(each_operator, (ast.Eq, ast.NotEq))
1847
- else "ordered"
1848
- )
1849
- comparison_kinds_by_constant.setdefault(compared_constant, set()).add(kind)
1850
- return comparison_kinds_by_constant
1851
-
1852
-
1853
- def _package_scan_python_files(file_path: str) -> list[Path]:
1854
- file = Path(file_path)
1855
- scan_root = (
1856
- file.parent.parent
1857
- if file.parent.name == LENGTH_CONFIG_SUBDIRECTORY_NAME
1858
- else file.parent
1859
- )
1860
- try:
1861
- all_python_files = sorted(scan_root.rglob(f"*{PYTHON_MODULE_FILE_SUFFIX}"))
1862
- except OSError:
1863
- return []
1864
- return [
1865
- each_file
1866
- for each_file in all_python_files[:LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT]
1867
- if each_file.resolve() != file.resolve()
1868
- and not is_test_file(str(each_file))
1869
- ]
1870
-
1871
-
1872
- def _exact_length_gate_constants_in_package(
1873
- file_path: str, all_length_constant_names: set[str]
1874
- ) -> set[str]:
1875
- aggregate_kinds: dict[str, set[str]] = {}
1876
- for each_file in _package_scan_python_files(file_path):
1877
- try:
1878
- sibling_tree = ast.parse(each_file.read_text(encoding="utf-8"))
1879
- except (OSError, SyntaxError, ValueError):
1880
- continue
1881
- for each_constant, each_kinds in _length_gate_comparison_kinds_in_tree(
1882
- sibling_tree, all_length_constant_names
1883
- ).items():
1884
- aggregate_kinds.setdefault(each_constant, set()).update(each_kinds)
1885
- return {
1886
- each_constant
1887
- for each_constant, each_kinds in aggregate_kinds.items()
1888
- if "equality" in each_kinds and "ordered" not in each_kinds
1889
- }
1890
-
1891
-
1892
- def check_docstring_length_constant_superlative_vs_exact_gate(
1893
- content: str, file_path: str
1894
- ) -> list[str]:
1895
- """Flag a length-constant docstring that calls an exact gate the "longest" form.
1896
-
1897
- The drift this catches: a config module defines an integer ``*_LENGTH``
1898
- constant and its docstring describes that constant with a superlative or
1899
- range word (``the longest color string the swatch accepts``), while the only
1900
- code that consumes the constant treats it as an exact-length equality gate
1901
- (``len(hex_color) != COLOR_AARRGGBB_LENGTH``) — a shorter string is rejected,
1902
- not accepted at a shorter length. The superlative prose implies a range of
1903
- accepted lengths the code never allows. The consumer lives in a sibling
1904
- module, so the check scans the constant module's package tree (its own
1905
- directory, or its parent package when the module sits in a ``config/``
1906
- subdirectory) for the comparison; it binds only when a length constant is
1907
- compared with ``==``/``!=`` against ``len(...)`` somewhere in that tree and
1908
- never with an ordered operator, so a constant genuinely used as a ceiling
1909
- (``len(x) <= LIMIT``) is left alone. The superlative phrase is read from the
1910
- module docstring only, and it is bound to a constant before flagging: a
1911
- constant is flagged when the docstring sentence carrying the phrase names
1912
- that constant, or when the phrase sits in a sentence naming no length
1913
- constant and the docstring never names the constant elsewhere — so a phrase
1914
- describing one constant (``MAX_NAME_LENGTH is the longest label``) does not
1915
- flag a different exact-gated constant documented on its own terms. This is
1916
- the deterministic exact-gate slice of Category O6/O8
1917
- docstring-vs-implementation drift; the cross-module free-prose variant where
1918
- the docstring never names the constant stays an audit-lane finding.
1919
-
1920
- Args:
1921
- content: The source text to inspect.
1922
- file_path: The path the source will be written to, used for exemptions
1923
- and to locate the package tree the consumer lives in.
1924
-
1925
- Returns:
1926
- One issue per length constant the package exact-gates while the module
1927
- docstring binds a superlative phrase to that constant, capped at the
1928
- module limit.
1929
- """
1930
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
1931
- return []
1932
- try:
1933
- parsed_tree = ast.parse(content)
1934
- except SyntaxError:
1935
- return []
1936
- all_length_constant_names = _module_level_length_constant_names(parsed_tree)
1937
- if not all_length_constant_names:
1938
- return []
1939
- phrase_by_constant, unattributed_phrase = _superlative_phrase_by_length_constant(
1940
- parsed_tree, all_length_constant_names
1941
- )
1942
- if not phrase_by_constant and not unattributed_phrase:
1943
- return []
1944
- module_docstring = ast.get_docstring(parsed_tree) or ""
1945
- named_length_constants = {
1946
- each_name for each_name in all_length_constant_names if each_name in module_docstring
1947
- }
1948
- exact_gate_constants = _exact_length_gate_constants_in_package(
1949
- file_path, all_length_constant_names
1950
- )
1951
- issues: list[str] = []
1952
- for each_constant in sorted(exact_gate_constants):
1953
- bound_phrase = phrase_by_constant.get(each_constant, "")
1954
- if not bound_phrase and each_constant not in named_length_constants:
1955
- bound_phrase = unattributed_phrase
1956
- if not bound_phrase:
1957
- continue
1958
- issues.append(
1959
- f"Line 1: module docstring says '{bound_phrase}' about "
1960
- f"{each_constant}, but the package compares len(...) against it only "
1961
- "with ==/!= (an exact-length gate that rejects every other length) — "
1962
- "state the exact required length, not a longest/maximum range "
1963
- "(Category O6 docstring-vs-implementation drift)"
1964
- )
1965
- if len(issues) >= MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES:
1966
- return issues[:MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES]
1967
- return issues[:MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES]
1968
-
1969
-
1970
- def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1971
- members_by_family: dict[str, set[str]] = {}
1972
- for each_node in ast.walk(parsed_tree):
1973
- if not isinstance(each_node, ast.Name):
1974
- continue
1975
- if not isinstance(each_node.ctx, ast.Load):
1976
- continue
1977
- if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_node.id):
1978
- continue
1979
- family_prefix, _, member_suffix = each_node.id.partition("_")
1980
- if not member_suffix:
1981
- continue
1982
- members_by_family.setdefault(family_prefix, set()).add(member_suffix.lower())
1983
- return members_by_family
1984
-
1985
-
1986
- def _eligible_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1987
- return {
1988
- each_family: each_members
1989
- for each_family, each_members in _referenced_constant_families(parsed_tree).items()
1990
- if len(each_members) >= MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK
1991
- }
1992
-
1993
-
1994
- def _docstring_snake_case_tokens(docstring_text: str) -> set[str]:
1995
- return set(DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN.findall(docstring_text))
1996
-
1997
-
1998
- def _docstring_stated_outcome_cardinals(docstring_text: str) -> set[int]:
1999
- return {
2000
- ALL_CARDINAL_NUMBER_WORD_VALUES[each_match.group(1).lower()]
2001
- for each_match in DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN.finditer(docstring_text)
2002
- }
2003
-
2004
-
2005
- def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[int, str]]:
2006
- docstrings_with_line: list[tuple[int, str]] = []
2007
- module_docstring = ast.get_docstring(parsed_tree) or ""
2008
- if module_docstring:
2009
- docstrings_with_line.append((1, module_docstring))
2010
- for each_node in ast.walk(parsed_tree):
2011
- if not isinstance(
2012
- each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
2013
- ):
2014
- continue
2015
- node_docstring = ast.get_docstring(each_node) or ""
2016
- if node_docstring:
2017
- docstrings_with_line.append((each_node.lineno, node_docstring))
2018
- return docstrings_with_line
2019
-
2020
-
2021
- def check_docstring_cardinal_count_matches_constant_family(
2022
- content: str, file_path: str
2023
- ) -> list[str]:
2024
- """Flag a docstring cardinal count that differs from a referenced constant family.
2025
-
2026
- The drift this catches: a docstring states a cardinal count of an outcome
2027
- family (``Covers the four outcome branches: ...``) and enumerates that
2028
- family's members in prose, while the module references a member set of the
2029
- same ``UPPER_SNAKE`` constant family whose size differs from the stated
2030
- count — above it and below it both trip. The prose
2031
- misdescribes the code — a reader trusts the count and the enumeration to
2032
- be the full set, but the module imports and exercises an outcome the summary
2033
- omits. The check binds only when the docstring states a cardinal beside an
2034
- outcome noun, names two or more members of one referenced family, leaves at
2035
- least one referenced family member unnamed, and the family member count
2036
- differs from every stated cardinal; a complete enumeration, a count that
2037
- matches the family, and a single passing mention are all left alone. This is
2038
- the deterministic cardinal-count slice of Category O6
2039
- docstring-prose-vs-implementation drift. The check runs on test files too,
2040
- since the drift class lives in a test-module summary.
2041
-
2042
- Args:
2043
- content: The source text to inspect.
2044
- file_path: Accepted for the uniform check interface; the body does not
2045
- read it.
2046
-
2047
- Returns:
2048
- One issue per docstring whose cardinal count and enumeration omit a
2049
- referenced constant-family member, capped at the module limit.
2050
- """
2051
- try:
2052
- parsed_tree = ast.parse(content)
2053
- except SyntaxError:
2054
- return []
2055
- eligible_families = _eligible_constant_families(parsed_tree)
2056
- if not eligible_families:
2057
- return []
2058
- issues: list[str] = []
2059
- for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
2060
- stated_cardinals = _docstring_stated_outcome_cardinals(each_docstring)
2061
- if not stated_cardinals:
2062
- continue
2063
- docstring_tokens = _docstring_snake_case_tokens(each_docstring)
2064
- if not docstring_tokens:
2065
- continue
2066
- for each_family in sorted(eligible_families):
2067
- family_members = eligible_families[each_family]
2068
- named_members = docstring_tokens & family_members
2069
- if len(named_members) < MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK:
2070
- continue
2071
- omitted_members = family_members - docstring_tokens
2072
- if not omitted_members:
2073
- continue
2074
- if len(family_members) in stated_cardinals:
2075
- continue
2076
- issues.append(
2077
- f"Line {each_line}: docstring names {sorted(stated_cardinals)} as the "
2078
- f"{each_family}_ count but the module references {len(family_members)} "
2079
- f"{each_family}_ constants — omits {sorted(omitted_members)}; match the "
2080
- "count and enumeration to the referenced family "
2081
- "(Category O6 docstring-vs-implementation drift)"
2082
- )
2083
- if len(issues) >= MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES:
2084
- return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
2085
- return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
2086
-
2087
-
2088
- def _called_callee_name(statement: ast.stmt) -> str:
2089
- candidate_expression: ast.expr | None = None
2090
- if isinstance(statement, ast.Expr):
2091
- candidate_expression = statement.value
2092
- elif isinstance(statement, ast.Assign):
2093
- candidate_expression = statement.value
2094
- elif isinstance(statement, ast.AnnAssign):
2095
- candidate_expression = statement.value
2096
- if candidate_expression is None:
2097
- return ""
2098
- if isinstance(candidate_expression, ast.Await):
2099
- candidate_expression = candidate_expression.value
2100
- if not isinstance(candidate_expression, ast.Call):
2101
- return ""
2102
- return _call_callee_name(candidate_expression)
2103
-
2104
-
2105
- def _called_callees_in_expression(expression: ast.expr) -> set[str]:
2106
- callees: set[str] = set()
2107
- for each_descendant in ast.walk(expression):
2108
- if not isinstance(each_descendant, ast.Call):
2109
- continue
2110
- callee_name = _call_callee_name(each_descendant)
2111
- if callee_name:
2112
- callees.add(callee_name)
2113
- return callees
2114
-
2115
-
2116
- def _linear_step_callees(
2117
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
2118
- ) -> set[str]:
2119
- callees: set[str] = set()
2120
- for each_statement in function_node.body:
2121
- callee_name = _called_callee_name(each_statement)
2122
- if callee_name:
2123
- callees.add(callee_name)
2124
- if isinstance(each_statement, ast.If):
2125
- callees |= _called_callees_in_expression(each_statement.test)
2126
- return callees
2127
-
2128
-
2129
- def _branch_guarded_dispatch_callees(
2130
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
2131
- ) -> set[str]:
2132
- callees: set[str] = set()
2133
- for each_statement in function_node.body:
2134
- if not isinstance(each_statement, ast.If):
2135
- continue
2136
- for each_branch_statement in each_statement.body + each_statement.orelse:
2137
- for each_descendant in ast.walk(each_branch_statement):
2138
- if not isinstance(each_descendant, ast.If):
2139
- continue
2140
- callees |= _called_callees_in_expression(each_descendant.test)
2141
- return callees
2142
-
2143
-
2144
- def _bare_callee_name(qualified_callee: str) -> str:
2145
- return qualified_callee.rsplit(".", 1)[-1]
2146
-
2147
-
2148
- def _docstring_names_all_callee_tokens(docstring_text: str, callee_name: str) -> bool:
2149
- bare_name = _bare_callee_name(callee_name)
2150
- lowered_docstring = docstring_text.lower()
2151
- if bare_name.lower() in lowered_docstring:
2152
- return True
2153
- callee_tokens = _name_tokens(bare_name)
2154
- if not callee_tokens:
2155
- return False
2156
- return all(each_token.lower() in lowered_docstring for each_token in callee_tokens)
2157
-
2158
-
2159
- def check_docstring_step_enumeration_dispatch_coverage(
2160
- content: str, file_path: str
2161
- ) -> list[str]:
2162
- """Flag a step-enumeration docstring that omits a conditional dispatch call.
2163
-
2164
- The drift this catches: a function whose docstring enumerates a linear
2165
- sequence of steps (``Navigates ..., searches ..., clicks ..., uploads ...``)
2166
- matching the body's linear-step calls, while the body also routes to a
2167
- corrective workflow step inside an ``if``/``elif`` branch — a cancel-and-reinitiate
2168
- or replace-target-row step — whose name the prose never spells out. A reader
2169
- who trusts the step list to be complete misses that the function can take that
2170
- conditional path. This is the deterministic slice of Category O4 (step-ordering
2171
- narrative): a body that guards a branch-only workflow step the enumeration omits.
2172
-
2173
- A linear-step call is one made as a top-level statement or inside the ``If.test``
2174
- guard of a top-level ``if`` (``if not await self.navigate(): return``). A
2175
- dispatch step is a call inside a guard (``If.test``) nested within an
2176
- ``if``/``elif`` branch (``if not await cancel_and_reinitiate_update(...): return``)
2177
- that is never also a linear step — the same control-flow-gating shape as a
2178
- linear step, so plain (unguarded) logging, screenshot, or method-on-local
2179
- calls inside a branch body are not dispatch steps. The check binds only when
2180
- the docstring already names two or more linear-step callees by their
2181
- underscore tokens, proving the prose is a step enumeration describing this
2182
- body. A dispatch-step callee with two or more underscore tokens, none of
2183
- whose tokens appear in the prose, is flagged.
2184
-
2185
- Args:
2186
- content: The source text to inspect.
2187
- file_path: The path the source will be written to, used for exemptions.
2188
-
2189
- Returns:
2190
- One issue per conditional dispatch call the step enumeration omits, capped
2191
- at the module limit.
2192
- """
2193
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
2194
- return []
2195
- try:
2196
- parsed_tree = ast.parse(content)
2197
- except SyntaxError:
2198
- return []
2199
- issues: list[str] = []
2200
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
2201
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2202
- continue
2203
- if _function_has_exempt_decorator(each_node):
2204
- continue
2205
- docstring_text = _function_docstring_text(each_node)
2206
- if not docstring_text:
2207
- continue
2208
- linear_step_callees = _linear_step_callees(each_node)
2209
- named_linear_steps = [
2210
- each_callee
2211
- for each_callee in linear_step_callees
2212
- if _docstring_names_all_callee_tokens(docstring_text, each_callee)
2213
- ]
2214
- if len(named_linear_steps) < MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK:
2215
- continue
2216
- branch_only_callees = (
2217
- _branch_guarded_dispatch_callees(each_node) - linear_step_callees
2218
- )
2219
- for each_callee in sorted(branch_only_callees):
2220
- if len(_name_tokens(_bare_callee_name(each_callee))) < MINIMUM_TOKENS_FOR_DISPATCH_CALLEE:
2221
- continue
2222
- if _docstring_names_all_callee_tokens(docstring_text, each_callee):
2223
- continue
2224
- issues.append(
2225
- f"Line {each_node.lineno}: {each_node.name}() docstring enumerates linear "
2226
- f"steps but omits the conditional dispatch step {each_callee}() the body "
2227
- "guards inside a branch — add the corrective-path step to the enumeration "
2228
- "(Category O4 step-ordering narrative drift)"
2229
- )
2230
- if len(issues) >= MAX_DOCSTRING_STEP_DISPATCH_ISSUES:
2231
- return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
2232
- return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
2233
-
2234
-
728
+ def _names_referenced_in_function(
729
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
730
+ ) -> set[str]:
731
+ return {
732
+ each_node.id
733
+ for each_node in ast.walk(function_node)
734
+ if isinstance(each_node, ast.Name)
735
+ }
736
+
737
+
2235
738
  def _imported_binding_names(import_node: ast.Import | ast.ImportFrom) -> set[str]:
2236
739
  bound_names: set[str] = set()
2237
740
  for each_alias in import_node.names:
@@ -2434,135 +937,6 @@ def check_docstring_names_undefined_constant(content: str, file_path: str) -> li
2434
937
  return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
2435
938
 
2436
939
 
2437
- def _args_entry_prose_by_argument(docstring_text: str) -> dict[str, str]:
2438
- docstring_lines = docstring_text.splitlines()
2439
- args_section_index = _find_args_section_index(docstring_lines)
2440
- if args_section_index is None:
2441
- return {}
2442
- prose_by_argument: dict[str, list[str]] = {}
2443
- current_argument_name: str | None = None
2444
- entry_indent: int | None = None
2445
- for each_line in docstring_lines[args_section_index + 1:]:
2446
- stripped_line = each_line.strip()
2447
- if not stripped_line:
2448
- continue
2449
- if _is_docstring_terminating_section_header(stripped_line):
2450
- break
2451
- current_indent = len(each_line) - len(each_line.lstrip())
2452
- if current_indent == 0:
2453
- break
2454
- if entry_indent is None:
2455
- entry_indent = current_indent
2456
- if current_indent <= entry_indent:
2457
- entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
2458
- if entry_match is not None:
2459
- current_argument_name = entry_match.group(1)
2460
- prose_by_argument[current_argument_name] = [stripped_line]
2461
- continue
2462
- if current_argument_name is not None:
2463
- prose_by_argument[current_argument_name].append(stripped_line)
2464
- return {
2465
- each_name: " ".join(each_lines).lower()
2466
- for each_name, each_lines in prose_by_argument.items()
2467
- }
2468
-
2469
-
2470
- def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
2471
- if any(
2472
- each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES
2473
- ):
2474
- return False
2475
- return any(
2476
- each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES
2477
- )
2478
-
2479
-
2480
- def _call_node_builds_explicit_start_range(call_node: ast.Call) -> bool:
2481
- callee = call_node.func
2482
- return (
2483
- isinstance(callee, ast.Name)
2484
- and callee.id == "range"
2485
- and len(call_node.args) >= MINIMUM_RANGE_ARGS_FOR_SPAN
2486
- )
2487
-
2488
-
2489
- def _function_body_scopes_a_span_by_intersection(
2490
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
2491
- ) -> bool:
2492
- builds_range_span = False
2493
- calls_span_scoper = False
2494
- for each_node in ast.walk(function_node):
2495
- if not isinstance(each_node, ast.Call):
2496
- continue
2497
- if _call_node_builds_explicit_start_range(each_node):
2498
- builds_range_span = True
2499
- if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
2500
- calls_span_scoper = True
2501
- return builds_range_span and calls_span_scoper
2502
-
2503
-
2504
- def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str) -> list[str]:
2505
- """Flag a docstring Args entry scoping a finding to one line over a span body.
2506
-
2507
- The drift this catches: an ``Args:`` entry whose prose says a finding blocks
2508
- "only when its block-anchor line is among the changed lines" (a single named
2509
- line) while the function body builds a ``range(...)`` span over the finding's
2510
- source lines and routes it through a span-intersection scoper that blocks when
2511
- ANY line of the span is among the changed lines. The Args sentence claims a
2512
- narrower single-line scope than the body applies, so an edit touching a
2513
- non-anchor line of the span still blocks — contradicting the Args entry. This
2514
- is the deterministic slice of Category O6 docstring-vs-implementation drift
2515
- for an Args single-line scope claim disagreeing with a span-intersection body.
2516
-
2517
- An entry is left alone when its prose says "any line of" / "any line in" /
2518
- "any of its lines" / "any span line" about its span, since that wording
2519
- matches the span body. The body is judged a span-intersection scoper only
2520
- when it both builds an explicit-start ``range(...)`` (two or more
2521
- arguments) and calls a known span scoper, so a body that scopes by a
2522
- single line never trips the check. Hook infrastructure is in scope here — the import-sort gate
2523
- that carries this drift class is itself a hook — and test files are exempt.
2524
-
2525
- Args:
2526
- content: The source text to inspect.
2527
- file_path: The path the source will be written to, used for exemptions.
2528
-
2529
- Returns:
2530
- One issue per function whose Args single-line scope claim disagrees with
2531
- its span-intersection body, capped at the module limit.
2532
- """
2533
- if is_test_file(file_path):
2534
- return []
2535
- try:
2536
- parsed_tree = ast.parse(content)
2537
- except SyntaxError:
2538
- return []
2539
- issues: list[str] = []
2540
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
2541
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2542
- continue
2543
- if _function_has_exempt_decorator(each_node):
2544
- continue
2545
- docstring_text = _function_docstring_text(each_node)
2546
- if not docstring_text:
2547
- continue
2548
- if not _function_body_scopes_a_span_by_intersection(each_node):
2549
- continue
2550
- for each_argument, each_prose in _args_entry_prose_by_argument(docstring_text).items():
2551
- if not _argument_prose_scopes_a_single_line(each_prose):
2552
- continue
2553
- issues.append(
2554
- f"Line {each_node.lineno}: {each_node.name}() Args entry '{each_argument}' "
2555
- "scopes a finding to a single line ('anchor line is among the changed lines') "
2556
- "while the body builds a range() span and scopes by span intersection — an "
2557
- "edit touching any non-anchor line of the span still blocks; restate the Args "
2558
- "entry as 'any line of its span is among the changed lines' (Category O6 "
2559
- "docstring-vs-implementation drift)"
2560
- )
2561
- if len(issues) >= MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES:
2562
- return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
2563
- return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
2564
-
2565
-
2566
940
  def _is_narrative_cut_header(stripped_line: str) -> bool:
2567
941
  return (
2568
942
  stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
@@ -2883,505 +1257,3 @@ def check_docstring_prose_wall_without_illustration(
2883
1257
  return scoped_issues[:MAX_DOCSTRING_PROSE_WALL_ISSUES]
2884
1258
 
2885
1259
 
2886
- def _raises_section_text(docstring_text: str) -> str:
2887
- docstring_lines = docstring_text.splitlines()
2888
- raises_section_lines: list[str] = []
2889
- inside_raises_section = False
2890
- for each_line in docstring_lines:
2891
- stripped_line = each_line.strip()
2892
- if stripped_line == "Raises:":
2893
- inside_raises_section = True
2894
- continue
2895
- if not inside_raises_section:
2896
- continue
2897
- if _is_docstring_terminating_section_header(stripped_line):
2898
- break
2899
- raises_section_lines.append(stripped_line)
2900
- return " ".join(raises_section_lines)
2901
-
2902
-
2903
- def _call_argument_by_keyword_or_position(
2904
- call_node: ast.Call, keyword_name: str, positional_index: int
2905
- ) -> ast.expr | None:
2906
- for each_keyword in call_node.keywords:
2907
- if each_keyword.arg == keyword_name:
2908
- return each_keyword.value
2909
- if positional_index >= len(call_node.args):
2910
- return None
2911
- if any(
2912
- isinstance(each_argument, ast.Starred)
2913
- for each_argument in call_node.args[: positional_index + 1]
2914
- ):
2915
- return None
2916
- return call_node.args[positional_index]
2917
-
2918
-
2919
- def _call_opens_zipfile_write_mode_writer(call_node: ast.Call) -> bool:
2920
- callee = call_node.func
2921
- if isinstance(callee, ast.Attribute):
2922
- callee_name = callee.attr
2923
- elif isinstance(callee, ast.Name):
2924
- callee_name = callee.id
2925
- else:
2926
- return False
2927
- if callee_name != ZIPFILE_WRITER_CLASS_NAME:
2928
- return False
2929
- mode_argument = _call_argument_by_keyword_or_position(
2930
- call_node, ZIPFILE_MODE_KEYWORD, ZIPFILE_MODE_POSITIONAL_INDEX
2931
- )
2932
- return (
2933
- isinstance(mode_argument, ast.Constant)
2934
- and mode_argument.value in ALL_ZIPFILE_WRITE_MODE_VALUES
2935
- )
2936
-
2937
-
2938
- def _zipfile_writer_forbids_zip64(call_node: ast.Call) -> bool:
2939
- allow_zip64_argument = _call_argument_by_keyword_or_position(
2940
- call_node, ZIPFILE_ALLOW_ZIP64_KEYWORD, ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX
2941
- )
2942
- return (
2943
- isinstance(allow_zip64_argument, ast.Constant)
2944
- and allow_zip64_argument.value is False
2945
- )
2946
-
2947
-
2948
- def _function_documents_unraisable_largezipfile(
2949
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
2950
- docstring_text: str,
2951
- ) -> bool:
2952
- if DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME not in _raises_section_text(docstring_text):
2953
- return False
2954
- write_mode_writers = [
2955
- each_descendant
2956
- for each_descendant in _walk_skipping_nested_functions(function_node)
2957
- if isinstance(each_descendant, ast.Call)
2958
- and _call_opens_zipfile_write_mode_writer(each_descendant)
2959
- ]
2960
- if not write_mode_writers:
2961
- return False
2962
- return not any(
2963
- _zipfile_writer_forbids_zip64(each_writer) for each_writer in write_mode_writers
2964
- )
2965
-
2966
-
2967
- def _attributes_section_entries(docstring_text: str) -> list[tuple[str, str]]:
2968
- """Return each ``name: description`` entry under an Attributes section.
2969
-
2970
- A continuation line indented under an entry joins that entry's description,
2971
- so a field whose description wraps across lines reads as one string. The
2972
- scan ends at the next Google-style section header or at the docstring end.
2973
-
2974
- Args:
2975
- docstring_text: The full class docstring text to scan.
2976
-
2977
- Returns:
2978
- One ``(field_name, description)`` pair per Attributes entry, in order.
2979
- """
2980
- all_lines = docstring_text.splitlines()
2981
- inside_section = False
2982
- entries: list[tuple[str, str]] = []
2983
- for each_line in all_lines:
2984
- stripped_line = each_line.strip()
2985
- if not inside_section:
2986
- if stripped_line in ("Attributes:", "Attrs:"):
2987
- inside_section = True
2988
- continue
2989
- if stripped_line.endswith(":") and not each_line.startswith((" ", "\t")):
2990
- break
2991
- if stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS:
2992
- break
2993
- entry_match = re.match(
2994
- r"^\s+(?P<field_name>[A-Za-z_][A-Za-z0-9_]*):\s*(?P<description>.*)$",
2995
- each_line,
2996
- )
2997
- if entry_match:
2998
- entries.append(
2999
- (entry_match.group("field_name"), entry_match.group("description"))
3000
- )
3001
- elif entries and stripped_line:
3002
- previous_name, previous_description = entries[-1]
3003
- entries[-1] = (previous_name, f"{previous_description} {stripped_line}")
3004
- return entries
3005
-
3006
- def _field_name_is_run_mode_flag(field_name: str) -> bool:
3007
- lowered_name = field_name.lower()
3008
- return any(
3009
- each_token in lowered_name
3010
- for each_token in ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS
3011
- )
3012
-
3013
- def _description_claims_per_record_write_outcome(description: str) -> bool:
3014
- lowered_description = description.lower()
3015
- has_write_outcome_phrase = any(
3016
- each_phrase in lowered_description
3017
- for each_phrase in ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES
3018
- )
3019
- has_run_mode_phrase = any(
3020
- each_phrase in lowered_description
3021
- for each_phrase in ALL_DOCSTRING_RUN_MODE_PHRASES
3022
- )
3023
- return has_write_outcome_phrase and not has_run_mode_phrase
3024
-
3025
- def check_docstring_field_runmode_outcome(content: str, file_path: str) -> list[str]:
3026
- """Flag a run-mode flag field documented as a per-record write outcome.
3027
-
3028
- The drift this catches: a dataclass or TypedDict field whose name marks a
3029
- run-mode flag (``is_dry_run``) is documented in the class Attributes block
3030
- with per-record write-outcome prose (``True when no STP was written``),
3031
- while the value is set the same way for every record from the run mode
3032
- (``is_dry_run=not is_execute``). An already-OK record in an execute run then
3033
- writes no file yet still stores ``False``, so the per-record prose misleads
3034
- every reader. The check binds only when the description carries a
3035
- write-outcome phrase and no run-mode phrase, so a field documented by its
3036
- run-mode meaning is left alone. This is the deterministic single-file slice
3037
- of Category O6 run-mode-versus-per-record docstring drift.
3038
-
3039
- Args:
3040
- content: The source text to inspect.
3041
- file_path: The path the source will be written to, used for exemptions.
3042
-
3043
- Returns:
3044
- One issue per run-mode flag field whose Attributes description claims a
3045
- per-record write outcome, capped at the module limit.
3046
- """
3047
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
3048
- return []
3049
- try:
3050
- parsed_tree = ast.parse(content)
3051
- except SyntaxError:
3052
- return []
3053
- issues: list[str] = []
3054
- for each_node in ast.walk(parsed_tree):
3055
- if not isinstance(each_node, ast.ClassDef):
3056
- continue
3057
- docstring_text = ast.get_docstring(each_node) or ""
3058
- if not docstring_text:
3059
- continue
3060
- for each_field_name, each_description in _attributes_section_entries(docstring_text):
3061
- if not _field_name_is_run_mode_flag(each_field_name):
3062
- continue
3063
- if not _description_claims_per_record_write_outcome(each_description):
3064
- continue
3065
- issues.append(
3066
- f"Line {each_node.lineno}: {each_node.name}.{each_field_name} is a "
3067
- "run-mode flag but its Attributes description claims a per-record "
3068
- "write outcome — restate it as the run-mode meaning the assignment "
3069
- "gives it (Category O6 run-mode-versus-per-record docstring drift)"
3070
- )
3071
- if len(issues) >= MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES:
3072
- return issues[:MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES]
3073
- return issues[:MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES]
3074
-
3075
-
3076
- def check_docstring_raises_unraisable_largezipfile(
3077
- content: str, file_path: str
3078
- ) -> list[str]:
3079
- """Flag a Raises clause naming LargeZipFile over a default-ZIP64 writer.
3080
-
3081
- The drift this catches: a function whose docstring Raises clause lists
3082
- ``zipfile.LargeZipFile`` while the function opens its ``zipfile.ZipFile``
3083
- writer in a write mode (``w``/``a``/``x``) with ``allowZip64`` left at its
3084
- default of True. The stdlib raises ``LargeZipFile`` only when an entry needs
3085
- ZIP64 AND ``allowZip64`` is False; with ZIP64 permitted the writer
3086
- transparently uses it and never raises. The Raises entry then documents an
3087
- exception the body cannot produce, so a caller guarding ``LargeZipFile`` on
3088
- the strength of the docstring guards an unreachable path. This is the
3089
- deterministic slice of Category O6 docstring-prose-vs-implementation drift
3090
- where a writer opened with default ZIP64 disagrees with a LargeZipFile
3091
- Raises clause.
3092
-
3093
- The check binds only when the function opens at least one write-mode
3094
- ``ZipFile`` and every such writer permits ZIP64, so a function that forbids
3095
- ZIP64 on any writer (``allowZip64=False``, by keyword or position), a
3096
- read-only open, and a function that opens no writer — where the exception may
3097
- propagate from a callee — are all left alone.
3098
-
3099
- Args:
3100
- content: The source text to inspect.
3101
- file_path: The path the source will be written to, used for exemptions.
3102
-
3103
- Returns:
3104
- One issue per function whose LargeZipFile Raises clause names an
3105
- unreachable exception, capped at the module limit.
3106
- """
3107
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
3108
- return []
3109
- try:
3110
- parsed_tree = ast.parse(content)
3111
- except SyntaxError:
3112
- return []
3113
- issues: list[str] = []
3114
- for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
3115
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
3116
- continue
3117
- if _function_has_exempt_decorator(each_node):
3118
- continue
3119
- docstring_text = _function_docstring_text(each_node)
3120
- if not docstring_text:
3121
- continue
3122
- if not _function_documents_unraisable_largezipfile(each_node, docstring_text):
3123
- continue
3124
- issues.append(
3125
- f"Line {each_node.lineno}: {each_node.name}() docstring Raises lists "
3126
- "zipfile.LargeZipFile, but the function opens its ZipFile writer with ZIP64 "
3127
- "permitted (allowZip64 defaults to True) — LargeZipFile raises only when "
3128
- "allowZip64 is False, so drop the entry or pass allowZip64=False "
3129
- "(Category O6 docstring-vs-implementation drift)"
3130
- )
3131
- if len(issues) >= MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES:
3132
- break
3133
- return issues[:MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES]
3134
-
3135
-
3136
- def _leading_summary_line(raw_docstring: str) -> str:
3137
- summary_paragraph = raw_docstring.strip().split("\n\n", maxsplit=1)[0]
3138
- return " ".join(summary_paragraph.split())
3139
-
3140
-
3141
- def _pointer_target_stem(raw_docstring: str) -> str:
3142
- marker_match = POINTER_TO_DELEGATE_PATTERN.search(raw_docstring)
3143
- return marker_match.group(1) if marker_match is not None else ""
3144
-
3145
-
3146
- def _listed_entries(wrapper_summary: str) -> list[str]:
3147
- marker_match = POINTER_TO_DELEGATE_PATTERN.search(wrapper_summary)
3148
- if marker_match is None:
3149
- return []
3150
- listed_text = wrapper_summary[: marker_match.start()].strip().rstrip(";,").strip()
3151
- normalized_text = re.sub(r"\s+and\s+", ", ", listed_text, flags=re.IGNORECASE)
3152
- return [
3153
- each_entry.strip()
3154
- for each_entry in normalized_text.split(",")
3155
- if each_entry.strip()
3156
- ]
3157
-
3158
-
3159
- def _words_of_entry(raw_entry: str) -> list[str]:
3160
- return [
3161
- each_token.lower()
3162
- for each_token in TOKEN_WORD_PATTERN.findall(raw_entry)
3163
- if each_token.lower() not in ALL_DELEGATION_ENUMERATION_STOP_TOKENS
3164
- ]
3165
-
3166
-
3167
- def _matches_any_word(delegate_summary: str, all_distinct_words: list[str]) -> bool:
3168
- lowered_summary = delegate_summary.lower()
3169
- return any(
3170
- re.search(
3171
- WORD_BOUNDARY_REGEX + re.escape(each_token) + WORD_BOUNDARY_REGEX,
3172
- lowered_summary,
3173
- )
3174
- is not None
3175
- for each_token in all_distinct_words
3176
- )
3177
-
3178
-
3179
- def _absent_entries(wrapper_summary: str, delegate_summary: str) -> list[str]:
3180
- all_entries = _listed_entries(wrapper_summary)
3181
- if len(all_entries) < MINIMUM_DELEGATION_ENUMERATION_ITEMS:
3182
- return []
3183
- if "," not in delegate_summary:
3184
- return []
3185
- absent_entry_texts: list[str] = []
3186
- for each_entry in all_entries:
3187
- distinct_words = _words_of_entry(each_entry)
3188
- if not distinct_words:
3189
- continue
3190
- if _matches_any_word(delegate_summary, distinct_words):
3191
- continue
3192
- absent_entry_texts.append(each_entry)
3193
- return absent_entry_texts
3194
-
3195
-
3196
- def _top_level_definitions_and_methods(
3197
- parsed_tree: ast.Module,
3198
- ) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
3199
- all_definitions_and_methods: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
3200
- for each_node in parsed_tree.body:
3201
- if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
3202
- all_definitions_and_methods.append(each_node)
3203
- elif isinstance(each_node, ast.ClassDef):
3204
- all_definitions_and_methods.extend(
3205
- each_member
3206
- for each_member in each_node.body
3207
- if isinstance(each_member, (ast.FunctionDef, ast.AsyncFunctionDef))
3208
- )
3209
- return all_definitions_and_methods
3210
-
3211
-
3212
- def _entries_by_function_name(parsed_tree: ast.Module) -> dict[str, tuple[int, str]]:
3213
- entries_by_name: dict[str, tuple[int, str]] = {}
3214
- ambiguous_names: set[str] = set()
3215
- for each_definition in _top_level_definitions_and_methods(parsed_tree):
3216
- if each_definition.name in entries_by_name:
3217
- ambiguous_names.add(each_definition.name)
3218
- continue
3219
- entries_by_name[each_definition.name] = (
3220
- each_definition.lineno,
3221
- ast.get_docstring(each_definition) or "",
3222
- )
3223
- for each_ambiguous_name in ambiguous_names:
3224
- del entries_by_name[each_ambiguous_name]
3225
- return entries_by_name
3226
-
3227
-
3228
- def _source_text_or_empty(source_path: Path) -> str:
3229
- try:
3230
- return source_path.read_text(encoding="utf-8")
3231
- except (OSError, UnicodeDecodeError):
3232
- return ""
3233
-
3234
-
3235
- def _parsed_module_or_none(source_text: str) -> ast.Module | None:
3236
- if not source_text:
3237
- return None
3238
- try:
3239
- return ast.parse(source_text)
3240
- except SyntaxError:
3241
- return None
3242
-
3243
-
3244
- def _outbound_pointer_issues(parsed_tree: ast.Module, file_path: str) -> list[str]:
3245
- parent_directory = Path(file_path).parent
3246
- issues: list[str] = []
3247
- delegate_entries_by_stem: dict[str, dict[str, tuple[int, str]]] = {}
3248
- for each_node in _top_level_definitions_and_methods(parsed_tree):
3249
- wrapper_docstring = ast.get_docstring(each_node) or ""
3250
- wrapper_summary = _leading_summary_line(wrapper_docstring)
3251
- target_stem = _pointer_target_stem(wrapper_summary)
3252
- if not target_stem:
3253
- continue
3254
- if target_stem not in delegate_entries_by_stem:
3255
- delegate_path = parent_directory / (target_stem + PYTHON_MODULE_FILE_SUFFIX)
3256
- delegate_tree = _parsed_module_or_none(_source_text_or_empty(delegate_path))
3257
- delegate_entries_by_stem[target_stem] = (
3258
- _entries_by_function_name(delegate_tree)
3259
- if delegate_tree is not None
3260
- else {}
3261
- )
3262
- delegate_entry = delegate_entries_by_stem[target_stem].get(each_node.name)
3263
- if delegate_entry is None:
3264
- continue
3265
- delegate_summary = _leading_summary_line(delegate_entry[1])
3266
- for each_entry in _absent_entries(wrapper_summary, delegate_summary):
3267
- issues.append(
3268
- f"Line {each_node.lineno}: {each_node.name}() names "
3269
- f"'{each_entry}' in its summary, but the summary of the "
3270
- f"same-named function in {target_stem} omits it - align the "
3271
- "two summaries (Category O6 docstring-vs-implementation drift)"
3272
- )
3273
- return issues
3274
-
3275
-
3276
- def _scannable_neighbor_paths(written_path: Path) -> list[Path]:
3277
- try:
3278
- all_neighbor_paths = sorted(
3279
- written_path.parent.glob("*" + PYTHON_MODULE_FILE_SUFFIX)
3280
- )
3281
- except OSError:
3282
- return []
3283
- scannable_paths: list[Path] = []
3284
- for each_path in all_neighbor_paths:
3285
- if each_path.name == written_path.name:
3286
- continue
3287
- if is_strict_test_file(str(each_path)):
3288
- continue
3289
- scannable_paths.append(each_path)
3290
- if len(scannable_paths) >= NEIGHBOR_SCAN_FILE_LIMIT:
3291
- break
3292
- return scannable_paths
3293
-
3294
-
3295
- def _inbound_pointer_issues(
3296
- neighbor_tree: ast.Module,
3297
- neighbor_name: str,
3298
- delegate_stem: str,
3299
- all_entries_by_name: dict[str, tuple[int, str]],
3300
- ) -> list[str]:
3301
- issues: list[str] = []
3302
- for each_node in _top_level_definitions_and_methods(neighbor_tree):
3303
- wrapper_docstring = ast.get_docstring(each_node) or ""
3304
- wrapper_summary = _leading_summary_line(wrapper_docstring)
3305
- if _pointer_target_stem(wrapper_summary) != delegate_stem:
3306
- continue
3307
- delegate_entry = all_entries_by_name.get(each_node.name)
3308
- if delegate_entry is None:
3309
- continue
3310
- delegate_summary = _leading_summary_line(delegate_entry[1])
3311
- for each_entry in _absent_entries(wrapper_summary, delegate_summary):
3312
- issues.append(
3313
- f"Line {delegate_entry[0]}: {each_node.name}() summary omits "
3314
- f"'{each_entry}', named by the pointing wrapper docstring in "
3315
- f"{neighbor_name} - reword that wrapper docstring in the same "
3316
- "change (Category O6 docstring-vs-implementation drift)"
3317
- )
3318
- return issues
3319
-
3320
-
3321
- def _neighbor_scan_issues(parsed_tree: ast.Module, file_path: str) -> list[str]:
3322
- written_path = Path(file_path)
3323
- all_entries_by_name = _entries_by_function_name(parsed_tree)
3324
- if not all_entries_by_name:
3325
- return []
3326
- issues: list[str] = []
3327
- for each_neighbor in _scannable_neighbor_paths(written_path):
3328
- neighbor_source = _source_text_or_empty(each_neighbor)
3329
- if POINTER_TO_DELEGATE_PATTERN.search(neighbor_source) is None:
3330
- continue
3331
- neighbor_tree = _parsed_module_or_none(neighbor_source)
3332
- if neighbor_tree is None:
3333
- continue
3334
- issues.extend(
3335
- _inbound_pointer_issues(
3336
- neighbor_tree,
3337
- each_neighbor.name,
3338
- written_path.stem,
3339
- all_entries_by_name,
3340
- )
3341
- )
3342
- return issues
3343
-
3344
-
3345
- def check_docstring_delegation_summary_enumeration_drift(
3346
- content: str, file_path: str
3347
- ) -> list[str]:
3348
- """Flag a delegating wrapper summary that its delegation target contradicts.
3349
-
3350
- The drift this catches: a thin wrapper method whose docstring summary
3351
- enumerates the actions it stands for and points at the home of the real
3352
- body ("Apply App Info, Russia, review note, publication edits; full doc
3353
- on `listing_edit_flow`"), while the same-named function in that named
3354
- sibling file carries a summary enumeration that omits one of those
3355
- actions. An edit that moves one action out of the delegated body and
3356
- rewords only the delegated docstring leaves the wrapper claiming work the
3357
- body skips. The gate fires from both sides. On a wrapper save, it resolves
3358
- the named sibling file beside the saved one, finds the same-named
3359
- function, and compares the two summary enumerations. On a delegated-body
3360
- save, it scans neighboring .py files for a wrapper docstring pointing at
3361
- the saved stem and runs the same comparison, so the save that removes the
3362
- action hears about the stranded wrapper docstring. A listed action counts
3363
- as named when any of its distinctive words appears at a word boundary in
3364
- the delegated summary. The comparison binds when the wrapper enumerates
3365
- two or more actions and the delegated summary itself holds a comma, so a
3366
- plain one-purpose delegated summary never gets compared. This covers the
3367
- deterministic delegating-wrapper slice of Category O6
3368
- docstring-prose-vs-implementation drift.
3369
-
3370
- Args:
3371
- content: The source text to inspect.
3372
- file_path: The path the source will be written to, used for exemptions
3373
- and to resolve the delegated or wrapping file beside it.
3374
-
3375
- Returns:
3376
- One issue per listed wrapper action the paired delegated summary
3377
- omits, capped at the module limit.
3378
- """
3379
- if is_strict_test_file(file_path) or is_hook_infrastructure(file_path):
3380
- return []
3381
- try:
3382
- parsed_tree = ast.parse(content)
3383
- except SyntaxError:
3384
- return []
3385
- issues = _outbound_pointer_issues(parsed_tree, file_path)
3386
- issues.extend(_neighbor_scan_issues(parsed_tree, file_path))
3387
- return issues[:MAX_DOCSTRING_DELEGATION_ENUMERATION_ISSUES]