claude-dev-env 1.75.0 → 1.77.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 (47) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/CLAUDE.md +2 -2
  9. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  10. package/hooks/blocking/code_rules_docstrings.py +378 -0
  11. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  12. package/hooks/blocking/code_rules_enforcer.py +48 -5
  13. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  14. package/hooks/blocking/code_rules_shared.py +8 -5
  15. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  16. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  21. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  22. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  23. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  24. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  25. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  27. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  28. package/hooks/blocking/verification_verdict_store.py +19 -5
  29. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  30. package/hooks/hooks_constants/blocking_check_limits.py +56 -0
  31. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
  33. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/claude-md-orphan-file.md +7 -8
  37. package/rules/docstring-prose-matches-implementation.md +5 -1
  38. package/rules/package-inventory-stale-entry.md +16 -0
  39. package/rules/plain-illustrative-docstrings.md +56 -0
  40. package/skills/anthropic-plan/CLAUDE.md +1 -1
  41. package/skills/anthropic-plan/SKILL.md +15 -2
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
  43. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
  44. package/skills/autoconverge/workflow/converge.mjs +86 -110
  45. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  46. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  47. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -31,23 +31,34 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
31
31
  ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
32
32
  ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
33
33
  ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
34
+ ALL_DOCSTRING_RUNON_JOINER_MARKERS,
34
35
  ALL_GENERIC_CHECK_NAME_TOKENS,
35
36
  ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
36
37
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
37
38
  DOCSTRING_REFERENCE_MARKER_WINDOW,
39
+ DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
40
+ DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
38
41
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
39
42
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
40
43
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
44
+ MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
41
45
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
42
46
  MAX_DOCSTRING_FORMAT_ISSUES,
43
47
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
44
48
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
49
+ MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
50
+ ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
51
+ ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
52
+ ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
53
+ MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
45
54
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
46
55
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
47
56
  MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
48
57
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
49
58
  MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
50
59
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
60
+ MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
61
+ MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
51
62
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
52
63
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
53
64
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
@@ -57,10 +68,13 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
57
68
  )
58
69
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
59
70
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
71
+ ALL_CARDINAL_NUMBER_WORD_VALUES,
60
72
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
61
73
  ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
62
74
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
63
75
  DOCSTRING_ARG_ENTRY_PATTERN,
76
+ DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN,
77
+ DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN,
64
78
  DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
65
79
  IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
66
80
  INLINE_CODE_TOKEN_PATTERN,
@@ -1111,6 +1125,122 @@ def check_docstring_returns_plural_cardinality(content: str, file_path: str) ->
1111
1125
  return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1112
1126
 
1113
1127
 
1128
+ def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1129
+ members_by_family: dict[str, set[str]] = {}
1130
+ for each_node in ast.walk(parsed_tree):
1131
+ if not isinstance(each_node, ast.Name):
1132
+ continue
1133
+ if not isinstance(each_node.ctx, ast.Load):
1134
+ continue
1135
+ if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_node.id):
1136
+ continue
1137
+ family_prefix, _, member_suffix = each_node.id.partition("_")
1138
+ if not member_suffix:
1139
+ continue
1140
+ members_by_family.setdefault(family_prefix, set()).add(member_suffix.lower())
1141
+ return members_by_family
1142
+
1143
+
1144
+ def _eligible_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1145
+ return {
1146
+ each_family: each_members
1147
+ for each_family, each_members in _referenced_constant_families(parsed_tree).items()
1148
+ if len(each_members) >= MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK
1149
+ }
1150
+
1151
+
1152
+ def _docstring_snake_case_tokens(docstring_text: str) -> set[str]:
1153
+ return set(DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN.findall(docstring_text))
1154
+
1155
+
1156
+ def _docstring_stated_outcome_cardinals(docstring_text: str) -> set[int]:
1157
+ return {
1158
+ ALL_CARDINAL_NUMBER_WORD_VALUES[each_match.group(1).lower()]
1159
+ for each_match in DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN.finditer(docstring_text)
1160
+ }
1161
+
1162
+
1163
+ def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[int, str]]:
1164
+ docstrings_with_line: list[tuple[int, str]] = []
1165
+ module_docstring = ast.get_docstring(parsed_tree) or ""
1166
+ if module_docstring:
1167
+ docstrings_with_line.append((1, module_docstring))
1168
+ for each_node in ast.walk(parsed_tree):
1169
+ if not isinstance(
1170
+ each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
1171
+ ):
1172
+ continue
1173
+ node_docstring = ast.get_docstring(each_node) or ""
1174
+ if node_docstring:
1175
+ docstrings_with_line.append((each_node.lineno, node_docstring))
1176
+ return docstrings_with_line
1177
+
1178
+
1179
+ def check_docstring_cardinal_count_matches_constant_family(
1180
+ content: str, file_path: str
1181
+ ) -> list[str]:
1182
+ """Flag a docstring cardinal count that under-names a referenced constant family.
1183
+
1184
+ The drift this catches: a docstring states a cardinal count of an outcome
1185
+ family (``Covers the four outcome branches: ...``) and enumerates that
1186
+ family's members in prose, while the module references more members of the
1187
+ same ``UPPER_SNAKE`` constant family than the count names. The prose
1188
+ under-describes the code — a reader trusts the count and the enumeration to
1189
+ be the full set, but the module imports and exercises an outcome the summary
1190
+ omits. The check binds only when the docstring states a cardinal beside an
1191
+ outcome noun, names two or more members of one referenced family, leaves at
1192
+ least one referenced family member unnamed, and the family member count
1193
+ differs from every stated cardinal; a complete enumeration, a count that
1194
+ matches the family, and a single passing mention are all left alone. This is
1195
+ the deterministic cardinal-count slice of Category O6
1196
+ docstring-prose-vs-implementation drift. The check runs on test files too,
1197
+ since the drift class lives in a test-module summary.
1198
+
1199
+ Args:
1200
+ content: The source text to inspect.
1201
+ file_path: The path the source will be written to, used for exemptions.
1202
+
1203
+ Returns:
1204
+ One issue per docstring whose cardinal count and enumeration omit a
1205
+ referenced constant-family member, capped at the module limit.
1206
+ """
1207
+ try:
1208
+ parsed_tree = ast.parse(content)
1209
+ except SyntaxError:
1210
+ return []
1211
+ eligible_families = _eligible_constant_families(parsed_tree)
1212
+ if not eligible_families:
1213
+ return []
1214
+ issues: list[str] = []
1215
+ for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
1216
+ stated_cardinals = _docstring_stated_outcome_cardinals(each_docstring)
1217
+ if not stated_cardinals:
1218
+ continue
1219
+ docstring_tokens = _docstring_snake_case_tokens(each_docstring)
1220
+ if not docstring_tokens:
1221
+ continue
1222
+ for each_family in sorted(eligible_families):
1223
+ family_members = eligible_families[each_family]
1224
+ named_members = docstring_tokens & family_members
1225
+ if len(named_members) < MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK:
1226
+ continue
1227
+ omitted_members = family_members - docstring_tokens
1228
+ if not omitted_members:
1229
+ continue
1230
+ if len(family_members) in stated_cardinals:
1231
+ continue
1232
+ issues.append(
1233
+ f"Line {each_line}: docstring names {sorted(stated_cardinals)} as the "
1234
+ f"{each_family}_ count but the module references {len(family_members)} "
1235
+ f"{each_family}_ constants — omits {sorted(omitted_members)}; match the "
1236
+ "count and enumeration to the referenced family "
1237
+ "(Category O6 docstring-vs-implementation drift)"
1238
+ )
1239
+ if len(issues) >= MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES:
1240
+ return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
1241
+ return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
1242
+
1243
+
1114
1244
  def _called_callee_name(statement: ast.stmt) -> str:
1115
1245
  candidate_expression: ast.expr | None = None
1116
1246
  if isinstance(statement, ast.Expr):
@@ -1458,3 +1588,251 @@ def check_docstring_names_undefined_constant(content: str, file_path: str) -> li
1458
1588
  if len(issues) >= MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES:
1459
1589
  return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
1460
1590
  return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
1591
+
1592
+
1593
+ def _args_entry_prose_by_argument(docstring_text: str) -> dict[str, str]:
1594
+ docstring_lines = docstring_text.splitlines()
1595
+ args_section_index = _find_args_section_index(docstring_lines)
1596
+ if args_section_index is None:
1597
+ return {}
1598
+ prose_by_argument: dict[str, list[str]] = {}
1599
+ current_argument_name: str | None = None
1600
+ entry_indent: int | None = None
1601
+ for each_line in docstring_lines[args_section_index + 1:]:
1602
+ stripped_line = each_line.strip()
1603
+ if not stripped_line:
1604
+ continue
1605
+ if _is_docstring_terminating_section_header(stripped_line):
1606
+ break
1607
+ current_indent = len(each_line) - len(each_line.lstrip())
1608
+ if current_indent == 0:
1609
+ break
1610
+ if entry_indent is None:
1611
+ entry_indent = current_indent
1612
+ if current_indent <= entry_indent:
1613
+ entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
1614
+ if entry_match is not None:
1615
+ current_argument_name = entry_match.group(1)
1616
+ prose_by_argument[current_argument_name] = [stripped_line]
1617
+ continue
1618
+ if current_argument_name is not None:
1619
+ prose_by_argument[current_argument_name].append(stripped_line)
1620
+ return {
1621
+ each_name: " ".join(each_lines).lower()
1622
+ for each_name, each_lines in prose_by_argument.items()
1623
+ }
1624
+
1625
+
1626
+ def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
1627
+ if any(
1628
+ each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES
1629
+ ):
1630
+ return False
1631
+ return any(
1632
+ each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES
1633
+ )
1634
+
1635
+
1636
+ def _call_node_builds_two_argument_range(call_node: ast.Call) -> bool:
1637
+ callee = call_node.func
1638
+ return (
1639
+ isinstance(callee, ast.Name)
1640
+ and callee.id == "range"
1641
+ and len(call_node.args) >= MINIMUM_TOKENS_FOR_DISPATCH_CALLEE
1642
+ )
1643
+
1644
+
1645
+ def _function_body_scopes_a_span_by_intersection(
1646
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1647
+ ) -> bool:
1648
+ builds_range_span = False
1649
+ calls_span_scoper = False
1650
+ for each_node in ast.walk(function_node):
1651
+ if not isinstance(each_node, ast.Call):
1652
+ continue
1653
+ if _call_node_builds_two_argument_range(each_node):
1654
+ builds_range_span = True
1655
+ if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
1656
+ calls_span_scoper = True
1657
+ return builds_range_span and calls_span_scoper
1658
+
1659
+
1660
+ def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str) -> list[str]:
1661
+ """Flag a docstring Args entry scoping a finding to one line over a span body.
1662
+
1663
+ The drift this catches: an ``Args:`` entry whose prose says a finding blocks
1664
+ "only when its block-anchor line is among the changed lines" (a single named
1665
+ line) while the function body builds a ``range(...)`` span over the finding's
1666
+ source lines and routes it through a span-intersection scoper that blocks when
1667
+ ANY line of the span is among the changed lines. The Args sentence claims a
1668
+ narrower single-line scope than the body applies, so an edit touching a
1669
+ non-anchor line of the span still blocks — contradicting the Args entry. This
1670
+ is the deterministic slice of Category O6 docstring-vs-implementation drift
1671
+ for an Args single-line scope claim disagreeing with a span-intersection body.
1672
+
1673
+ An entry is left alone when its prose says "any line of" / "any line in" its
1674
+ span, since that wording matches the span body. The body is judged a
1675
+ span-intersection scoper only when it both builds a two-argument ``range(...)``
1676
+ and calls a known span scoper, so a body that scopes by a single line never
1677
+ trips the check. Hook infrastructure is in scope here — the import-sort gate
1678
+ that carries this drift class is itself a hook — and test files are exempt.
1679
+
1680
+ Args:
1681
+ content: The source text to inspect.
1682
+ file_path: The path the source will be written to, used for exemptions.
1683
+
1684
+ Returns:
1685
+ One issue per function whose Args single-line scope claim disagrees with
1686
+ its span-intersection body, capped at the module limit.
1687
+ """
1688
+ if is_test_file(file_path):
1689
+ return []
1690
+ try:
1691
+ parsed_tree = ast.parse(content)
1692
+ except SyntaxError:
1693
+ return []
1694
+ issues: list[str] = []
1695
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1696
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1697
+ continue
1698
+ if _function_has_exempt_decorator(each_node):
1699
+ continue
1700
+ docstring_text = _function_docstring_text(each_node)
1701
+ if not docstring_text:
1702
+ continue
1703
+ if not _function_body_scopes_a_span_by_intersection(each_node):
1704
+ continue
1705
+ for each_argument, each_prose in _args_entry_prose_by_argument(docstring_text).items():
1706
+ if not _argument_prose_scopes_a_single_line(each_prose):
1707
+ continue
1708
+ issues.append(
1709
+ f"Line {each_node.lineno}: {each_node.name}() Args entry '{each_argument}' "
1710
+ "scopes a finding to a single line ('anchor line is among the changed lines') "
1711
+ "while the body builds a range() span and scopes by span intersection — an "
1712
+ "edit touching any non-anchor line of the span still blocks; restate the Args "
1713
+ "entry as 'any line of its span is among the changed lines' (Category O6 "
1714
+ "docstring-vs-implementation drift)"
1715
+ )
1716
+ if len(issues) >= MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES:
1717
+ return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
1718
+ return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
1719
+
1720
+
1721
+ def _is_narrative_cut_header(stripped_line: str) -> bool:
1722
+ return (
1723
+ stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
1724
+ or stripped_line in ALL_DOCSTRING_ARGS_SECTION_HEADERS
1725
+ )
1726
+
1727
+
1728
+ def _docstring_narrative_text(docstring_text: str) -> str:
1729
+ narrative_lines: list[str] = []
1730
+ for each_line in docstring_text.splitlines():
1731
+ stripped_line = each_line.strip()
1732
+ if _is_narrative_cut_header(stripped_line):
1733
+ break
1734
+ narrative_lines.append(stripped_line)
1735
+ return " ".join(narrative_lines)
1736
+
1737
+
1738
+ def _sentence_word_count(sentence_text: str) -> int:
1739
+ return sum(
1740
+ 1
1741
+ for each_token in sentence_text.split()
1742
+ if any(each_character.isalnum() for each_character in each_token)
1743
+ )
1744
+
1745
+
1746
+ def _sentence_carries_joiner_marker(sentence_text: str) -> bool:
1747
+ return any(
1748
+ each_marker in sentence_text for each_marker in ALL_DOCSTRING_RUNON_JOINER_MARKERS
1749
+ )
1750
+
1751
+
1752
+ def _runon_sentences(narrative_text: str) -> list[str]:
1753
+ flagged_sentences: list[str] = []
1754
+ for each_sentence in DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN.split(narrative_text):
1755
+ stripped_sentence = each_sentence.strip()
1756
+ if not stripped_sentence:
1757
+ continue
1758
+ if _sentence_word_count(stripped_sentence) <= DOCSTRING_RUNON_SENTENCE_WORD_LIMIT:
1759
+ continue
1760
+ if not _sentence_carries_joiner_marker(stripped_sentence):
1761
+ continue
1762
+ flagged_sentences.append(stripped_sentence)
1763
+ return flagged_sentences
1764
+
1765
+
1766
+ def _documentable_docstring_targets(
1767
+ parsed_tree: ast.Module,
1768
+ ) -> list[tuple[int, str, str]]:
1769
+ documentable_targets: list[tuple[int, str, str]] = []
1770
+ module_docstring = ast.get_docstring(parsed_tree)
1771
+ if module_docstring and parsed_tree.body:
1772
+ documentable_targets.append((parsed_tree.body[0].lineno, "module", module_docstring))
1773
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1774
+ if isinstance(each_node, ast.ClassDef):
1775
+ class_docstring = ast.get_docstring(each_node)
1776
+ if class_docstring:
1777
+ documentable_targets.append((each_node.lineno, each_node.name, class_docstring))
1778
+ continue
1779
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1780
+ continue
1781
+ if _function_is_private_or_dunder(each_node.name):
1782
+ continue
1783
+ if _function_has_exempt_decorator(each_node):
1784
+ continue
1785
+ function_docstring = _function_docstring_text(each_node)
1786
+ if function_docstring:
1787
+ documentable_targets.append(
1788
+ (each_node.lineno, f"{each_node.name}()", function_docstring)
1789
+ )
1790
+ return documentable_targets
1791
+
1792
+
1793
+ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
1794
+ """Flag a docstring narrative sentence that reads as a dense run-on wall.
1795
+
1796
+ A readable docstring breaks its narrative into short sentences a general
1797
+ developer follows on the first read. The one mechanical mark of a wall is a
1798
+ single sentence that runs past the word limit while chaining clauses with an
1799
+ em-dash or a semicolon. This check inspects the narrative prose of module,
1800
+ class, and public-function docstrings — the text before the first structured
1801
+ section header (``Args:``, ``Arguments:``, ``Returns:``, ``Yields:``,
1802
+ ``Raises:``, ``Note:``, ``Notes:``, ``Example:``, or ``Examples:``) — and
1803
+ reports a sentence that is both over the word limit and joined by one of those
1804
+ marks.
1805
+ Whether the prose paints a concrete, illustrative picture is judgment the
1806
+ plain-illustrative-docstrings audit lane carries; this gate catches only the
1807
+ run-on mark.
1808
+
1809
+ Args:
1810
+ content: The source text to inspect.
1811
+ file_path: The path the source will be written to, used for exemptions.
1812
+
1813
+ Returns:
1814
+ One issue per docstring whose narrative carries a run-on sentence, capped
1815
+ at the module limit.
1816
+ """
1817
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1818
+ return []
1819
+ try:
1820
+ parsed_tree = ast.parse(content)
1821
+ except SyntaxError:
1822
+ return []
1823
+ issues: list[str] = []
1824
+ for each_line_number, each_label, each_docstring in _documentable_docstring_targets(
1825
+ parsed_tree
1826
+ ):
1827
+ flagged_sentences = _runon_sentences(_docstring_narrative_text(each_docstring))
1828
+ if not flagged_sentences:
1829
+ continue
1830
+ run_on_word_count = _sentence_word_count(flagged_sentences[0])
1831
+ issues.append(
1832
+ f"Line {each_line_number}: {each_label} docstring carries a {run_on_word_count}-word "
1833
+ "run-on sentence — break the narrative into short, illustrative sentences a general "
1834
+ "developer reads in one pass (plain-illustrative-docstrings)"
1835
+ )
1836
+ if len(issues) >= MAX_DOCSTRING_RUNON_SENTENCE_ISSUES:
1837
+ break
1838
+ return issues[:MAX_DOCSTRING_RUNON_SENTENCE_ISSUES]