claude-dev-env 1.83.0 → 1.85.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 (186) hide show
  1. package/_shared/pr-loop/audit-contract.md +24 -12
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
  6. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  7. package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
  8. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  9. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  13. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
  14. package/agents/clean-coder.md +3 -0
  15. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  16. package/bin/install.mjs +16 -8
  17. package/bin/install.test.mjs +17 -6
  18. package/commands/CLAUDE.md +0 -1
  19. package/docs/CODE_RULES.md +1 -1
  20. package/hooks/blocking/CLAUDE.md +0 -4
  21. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  22. package/hooks/blocking/code_rules_constants_config.py +5 -0
  23. package/hooks/blocking/code_rules_docstrings.py +133 -19
  24. package/hooks/blocking/code_rules_enforcer.py +4 -0
  25. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  26. package/hooks/blocking/code_rules_magic_values.py +5 -0
  27. package/hooks/blocking/code_rules_naming_collection.py +15 -8
  28. package/hooks/blocking/code_rules_paired_test.py +3 -2
  29. package/hooks/blocking/code_rules_shared.py +34 -0
  30. package/hooks/blocking/code_rules_string_magic.py +1 -1
  31. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  33. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  36. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  37. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  38. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  39. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  40. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  41. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  42. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  43. package/hooks/hooks_constants/CLAUDE.md +0 -3
  44. package/hooks/hooks_constants/blocking_check_limits.py +4 -0
  45. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  46. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  47. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  48. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  49. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  50. package/hooks/workflow/CLAUDE.md +2 -8
  51. package/package.json +1 -1
  52. package/rules/CLAUDE.md +1 -1
  53. package/rules/docstring-prose-matches-implementation.md +1 -1
  54. package/rules/env-var-table-code-drift.md +1 -1
  55. package/rules/package-inventory-stale-entry.md +1 -1
  56. package/rules/paired-test-coverage.md +1 -1
  57. package/rules/plain-illustrative-docstrings.md +35 -1
  58. package/skills/CLAUDE.md +6 -1
  59. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  60. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  61. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  62. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  63. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  64. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  65. package/skills/autoconverge/SKILL.md +82 -58
  66. package/skills/autoconverge/reference/closing-report.md +6 -6
  67. package/skills/autoconverge/reference/convergence.md +17 -15
  68. package/skills/autoconverge/reference/gotchas.md +6 -3
  69. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  70. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  71. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  72. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  73. package/skills/autoconverge/workflow/converge.mjs +71 -74
  74. package/skills/autoconverge/workflow/render_report.py +7 -11
  75. package/skills/bugteam/CLAUDE.md +1 -1
  76. package/skills/bugteam/PROMPTS.md +7 -6
  77. package/skills/bugteam/SKILL.md +25 -72
  78. package/skills/bugteam/reference/CLAUDE.md +1 -3
  79. package/skills/bugteam/reference/README.md +1 -1
  80. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  81. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  82. package/skills/bugteam/reference/team-setup.md +8 -7
  83. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  84. package/skills/bugteam/scripts/README.md +0 -4
  85. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  86. package/skills/code/SKILL.md +2 -0
  87. package/skills/copilot-review/CLAUDE.md +1 -1
  88. package/skills/copilot-review/SKILL.md +25 -23
  89. package/skills/findbugs/CLAUDE.md +2 -2
  90. package/skills/findbugs/SKILL.md +22 -83
  91. package/skills/fixbugs/SKILL.md +2 -4
  92. package/skills/log-audit/CLAUDE.md +20 -0
  93. package/skills/log-audit/SKILL.md +68 -0
  94. package/skills/log-audit/reference/CLAUDE.md +9 -0
  95. package/skills/log-audit/reference/charter.md +52 -0
  96. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  97. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  98. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  99. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  100. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  101. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  102. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  103. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  104. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  105. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  106. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  107. package/skills/monitor-open-prs/SKILL.md +2 -2
  108. package/skills/post-audit-findings/SKILL.md +84 -0
  109. package/skills/pr-converge/CLAUDE.md +2 -0
  110. package/skills/pr-converge/SKILL.md +99 -60
  111. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  112. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  113. package/skills/pr-converge/reference/examples.md +5 -5
  114. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  115. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  116. package/skills/pr-converge/reference/per-tick.md +24 -45
  117. package/skills/pr-converge/reference/state-schema.md +27 -0
  118. package/skills/pr-converge/scripts/README.md +3 -5
  119. package/skills/pr-fix-protocol/SKILL.md +70 -0
  120. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  121. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  122. package/skills/pr-scope-resolve/SKILL.md +48 -0
  123. package/skills/qbug/CLAUDE.md +4 -4
  124. package/skills/qbug/SKILL.md +46 -144
  125. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  126. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  127. package/skills/reviewer-gates/SKILL.md +96 -0
  128. package/skills/session-log/CLAUDE.md +7 -7
  129. package/skills/session-log/SKILL.md +27 -44
  130. package/skills/test_markdown_link_integrity.py +103 -0
  131. package/commands/doc-gist.md +0 -16
  132. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  133. package/hooks/blocking/conftest.py +0 -30
  134. package/hooks/blocking/md_path_exemptions.py +0 -224
  135. package/hooks/blocking/md_to_html_blocker.py +0 -155
  136. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  137. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  138. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  139. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  140. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  141. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  142. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  143. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  144. package/hooks/workflow/md_to_html_companion.py +0 -358
  145. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  146. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  147. package/skills/bugteam/reference/audit-contract.md +0 -163
  148. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  149. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  150. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  151. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  152. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  153. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  154. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  155. package/skills/doc-gist/CLAUDE.md +0 -25
  156. package/skills/doc-gist/SKILL.md +0 -97
  157. package/skills/doc-gist/references/CLAUDE.md +0 -9
  158. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  159. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  160. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  161. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  162. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  163. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  164. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  165. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  166. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  167. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  168. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  169. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  170. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  171. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  172. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  173. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  174. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  175. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  176. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  177. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  178. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  179. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  180. package/skills/doc-gist/references/examples/README.md +0 -5
  181. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  182. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  183. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  184. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  185. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  186. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -53,6 +53,8 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
53
53
  ALL_ZIPFILE_WRITE_MODE_VALUES,
54
54
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
55
55
  DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME,
56
+ DOCSTRING_NARRATIVE_LINE_JOIN_SEPARATOR,
57
+ DOCSTRING_NARRATIVE_PROSE_LINE_LIMIT,
56
58
  DOCSTRING_REFERENCE_MARKER_WINDOW,
57
59
  DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
58
60
  DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
@@ -72,6 +74,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
72
74
  MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES,
73
75
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
74
76
  MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES,
77
+ MAX_DOCSTRING_PROSE_WALL_ISSUES,
75
78
  MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES,
76
79
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
77
80
  MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
@@ -90,6 +93,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
90
93
  MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
91
94
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
92
95
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
96
+ MINIMUM_RANGE_ARGS_FOR_SPAN,
93
97
  MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN,
94
98
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
95
99
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
@@ -2016,13 +2020,14 @@ def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[in
2016
2020
  def check_docstring_cardinal_count_matches_constant_family(
2017
2021
  content: str, file_path: str
2018
2022
  ) -> list[str]:
2019
- """Flag a docstring cardinal count that under-names a referenced constant family.
2023
+ """Flag a docstring cardinal count that differs from a referenced constant family.
2020
2024
 
2021
2025
  The drift this catches: a docstring states a cardinal count of an outcome
2022
2026
  family (``Covers the four outcome branches: ...``) and enumerates that
2023
- family's members in prose, while the module references more members of the
2024
- same ``UPPER_SNAKE`` constant family than the count names. The prose
2025
- under-describes the code a reader trusts the count and the enumeration to
2027
+ family's members in prose, while the module references a member set of the
2028
+ same ``UPPER_SNAKE`` constant family whose size differs from the stated
2029
+ countabove it and below it both trip. The prose
2030
+ misdescribes the code — a reader trusts the count and the enumeration to
2026
2031
  be the full set, but the module imports and exercises an outcome the summary
2027
2032
  omits. The check binds only when the docstring states a cardinal beside an
2028
2033
  outcome noun, names two or more members of one referenced family, leaves at
@@ -2035,7 +2040,8 @@ def check_docstring_cardinal_count_matches_constant_family(
2035
2040
 
2036
2041
  Args:
2037
2042
  content: The source text to inspect.
2038
- file_path: The path the source will be written to, used for exemptions.
2043
+ file_path: Accepted for the uniform check interface; the body does not
2044
+ read it.
2039
2045
 
2040
2046
  Returns:
2041
2047
  One issue per docstring whose cardinal count and enumeration omit a
@@ -2470,12 +2476,12 @@ def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
2470
2476
  )
2471
2477
 
2472
2478
 
2473
- def _call_node_builds_two_argument_range(call_node: ast.Call) -> bool:
2479
+ def _call_node_builds_explicit_start_range(call_node: ast.Call) -> bool:
2474
2480
  callee = call_node.func
2475
2481
  return (
2476
2482
  isinstance(callee, ast.Name)
2477
2483
  and callee.id == "range"
2478
- and len(call_node.args) >= MINIMUM_TOKENS_FOR_DISPATCH_CALLEE
2484
+ and len(call_node.args) >= MINIMUM_RANGE_ARGS_FOR_SPAN
2479
2485
  )
2480
2486
 
2481
2487
 
@@ -2487,7 +2493,7 @@ def _function_body_scopes_a_span_by_intersection(
2487
2493
  for each_node in ast.walk(function_node):
2488
2494
  if not isinstance(each_node, ast.Call):
2489
2495
  continue
2490
- if _call_node_builds_two_argument_range(each_node):
2496
+ if _call_node_builds_explicit_start_range(each_node):
2491
2497
  builds_range_span = True
2492
2498
  if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
2493
2499
  calls_span_scoper = True
@@ -2507,11 +2513,12 @@ def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str)
2507
2513
  is the deterministic slice of Category O6 docstring-vs-implementation drift
2508
2514
  for an Args single-line scope claim disagreeing with a span-intersection body.
2509
2515
 
2510
- An entry is left alone when its prose says "any line of" / "any line in" its
2511
- span, since that wording matches the span body. The body is judged a
2512
- span-intersection scoper only when it both builds a two-argument ``range(...)``
2513
- and calls a known span scoper, so a body that scopes by a single line never
2514
- trips the check. Hook infrastructure is in scope here the import-sort gate
2516
+ An entry is left alone when its prose says "any line of" / "any line in" /
2517
+ "any of its lines" / "any span line" about its span, since that wording
2518
+ matches the span body. The body is judged a span-intersection scoper only
2519
+ when it both builds an explicit-start ``range(...)`` (two or more
2520
+ arguments) and calls a known span scoper, so a body that scopes by a
2521
+ single line never trips the check. Hook infrastructure is in scope here — the import-sort gate
2515
2522
  that carries this drift class is itself a hook — and test files are exempt.
2516
2523
 
2517
2524
  Args:
@@ -2562,14 +2569,71 @@ def _is_narrative_cut_header(stripped_line: str) -> bool:
2562
2569
  )
2563
2570
 
2564
2571
 
2565
- def _docstring_narrative_text(docstring_text: str) -> str:
2566
- narrative_lines: list[str] = []
2567
- for each_line in docstring_text.splitlines():
2568
- stripped_line = each_line.strip()
2572
+ def _docstring_narrative_partition(docstring_text: str) -> tuple[list[str], bool]:
2573
+ """Split a docstring narrative into its prose lines and whether it shows an example.
2574
+
2575
+ Walk the narrative up to the first ``Args:``/``Returns:`` cut header and sort
2576
+ every line into one of two buckets::
2577
+
2578
+ Render the report so the reader sees how it ended. <- prose line
2579
+ A finished run versus an interrupted one:: <- prose line (opener)
2580
+ theme_42 -- interrupted -> marked in-flight -- indented example body
2581
+ OK: a clean run reads differently in the log -- indented example body
2582
+ The reader reads the final outcome at a glance. <- prose line
2583
+
2584
+ A ``>>>`` doctest region and a ``::`` block with an indented body both count as
2585
+ an illustration, so their lines never join the prose bucket. The caller reads
2586
+ the prose count to size the wall and the flag to know whether an example is present.
2587
+ """
2588
+ prose_lines: list[str] = []
2589
+ has_illustration = False
2590
+ all_lines = docstring_text.splitlines()
2591
+ total_lines = len(all_lines)
2592
+ line_index = 0
2593
+ while line_index < total_lines:
2594
+ raw_line = all_lines[line_index]
2595
+ stripped_line = raw_line.strip()
2569
2596
  if _is_narrative_cut_header(stripped_line):
2570
2597
  break
2571
- narrative_lines.append(stripped_line)
2572
- return " ".join(narrative_lines)
2598
+ if stripped_line.startswith(">>>"):
2599
+ has_illustration = True
2600
+ line_index += 1
2601
+ while line_index < total_lines:
2602
+ inner_stripped = all_lines[line_index].strip()
2603
+ if not inner_stripped or _is_narrative_cut_header(inner_stripped):
2604
+ break
2605
+ line_index += 1
2606
+ continue
2607
+ if stripped_line.endswith("::"):
2608
+ opener_indent = len(raw_line) - len(raw_line.lstrip())
2609
+ probe_index = line_index + 1
2610
+ has_indented_body = False
2611
+ while probe_index < total_lines:
2612
+ probe_raw = all_lines[probe_index]
2613
+ probe_stripped = probe_raw.strip()
2614
+ if not probe_stripped:
2615
+ probe_index += 1
2616
+ continue
2617
+ probe_indent = len(probe_raw) - len(probe_raw.lstrip())
2618
+ if probe_indent > opener_indent:
2619
+ has_indented_body = True
2620
+ probe_index += 1
2621
+ continue
2622
+ break
2623
+ if has_indented_body:
2624
+ has_illustration = True
2625
+ prose_lines.append(stripped_line)
2626
+ line_index = probe_index
2627
+ continue
2628
+ if stripped_line:
2629
+ prose_lines.append(stripped_line)
2630
+ line_index += 1
2631
+ return prose_lines, has_illustration
2632
+
2633
+
2634
+ def _docstring_narrative_text(docstring_text: str) -> str:
2635
+ prose_lines, _has_illustration = _docstring_narrative_partition(docstring_text)
2636
+ return DOCSTRING_NARRATIVE_LINE_JOIN_SEPARATOR.join(prose_lines)
2573
2637
 
2574
2638
 
2575
2639
  def _sentence_word_count(sentence_text: str) -> int:
@@ -2675,6 +2739,56 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
2675
2739
  return issues[:MAX_DOCSTRING_RUNON_SENTENCE_ISSUES]
2676
2740
 
2677
2741
 
2742
+ def check_docstring_prose_wall_without_illustration(content: str, file_path: str) -> list[str]:
2743
+ """Flag a summary that tells for many sentences and shows nothing.
2744
+
2745
+ A reader trusts the opening to paint a scene. A run of short sentences with
2746
+ no worked example leaves the reader piecing that scene together alone::
2747
+
2748
+ A calm voyage ends well for every vessel. <- one more line
2749
+ A halted voyage marks the vessel it neared. <- and another
2750
+ ... more like these, no worked example ... flag: a wall, no scene
2751
+ A calm voyage versus a halted one:: ok: a worked example
2752
+ a lone vessel -- halted -> marked mid-voyage
2753
+
2754
+ Past the prose-line limit with no ``::`` listing and no ``>>>`` doctest, this
2755
+ fires. A narrative that shows a worked example, or one at the limit, passes.
2756
+
2757
+ Args:
2758
+ content: The source text to inspect.
2759
+ file_path: The path the source will be written to, used for exemptions.
2760
+
2761
+ Returns:
2762
+ One issue per summary that runs a wall of sentences with no worked
2763
+ example, capped at the issue limit for the rule.
2764
+ """
2765
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
2766
+ return []
2767
+ try:
2768
+ parsed_tree = ast.parse(content)
2769
+ except SyntaxError:
2770
+ return []
2771
+ issues: list[str] = []
2772
+ for each_line_number, each_label, each_docstring in _documentable_docstring_targets(
2773
+ parsed_tree
2774
+ ):
2775
+ prose_lines, has_illustration = _docstring_narrative_partition(each_docstring)
2776
+ if has_illustration:
2777
+ continue
2778
+ prose_line_count = len(prose_lines)
2779
+ if prose_line_count <= DOCSTRING_NARRATIVE_PROSE_LINE_LIMIT:
2780
+ continue
2781
+ issues.append(
2782
+ f"Line {each_line_number}: {each_label} summary runs {prose_line_count} "
2783
+ "narrative lines with no worked example - show, don't tell: swap the wall for a "
2784
+ "'::' listing (a sample input, an annotated outcome, ok/flag contrast rows) and "
2785
+ "keep the narrative to a few short lines (plain-illustrative-docstrings)"
2786
+ )
2787
+ if len(issues) >= MAX_DOCSTRING_PROSE_WALL_ISSUES:
2788
+ break
2789
+ return issues[:MAX_DOCSTRING_PROSE_WALL_ISSUES]
2790
+
2791
+
2678
2792
  def _raises_section_text(docstring_text: str) -> str:
2679
2793
  docstring_lines = docstring_text.splitlines()
2680
2794
  raises_section_lines: list[str] = []
@@ -87,6 +87,7 @@ from code_rules_docstrings import ( # noqa: E402
87
87
  check_docstring_no_consumer_claim,
88
88
  check_docstring_no_inline_literal_claim,
89
89
  check_docstring_no_network_claim_with_metadata_access,
90
+ check_docstring_prose_wall_without_illustration,
90
91
  check_docstring_punctuation_mark_enumeration_coverage,
91
92
  check_docstring_raises_unraisable_largezipfile,
92
93
  check_docstring_returns_plural_cardinality,
@@ -351,6 +352,9 @@ def validate_content(
351
352
  check_class_docstring_names_public_methods(effective_content, file_path)
352
353
  )
353
354
  all_issues.extend(check_docstring_runon_sentence(effective_content, file_path))
355
+ all_issues.extend(
356
+ check_docstring_prose_wall_without_illustration(effective_content, file_path)
357
+ )
354
358
  all_issues.extend(
355
359
  check_module_docstring_names_public_checks(effective_content, file_path)
356
360
  )
@@ -244,7 +244,8 @@ def check_import_block_sorted(
244
244
  give ruff the filename it needs for config discovery.
245
245
  all_changed_lines: Post-edit line numbers the current edit touched, or
246
246
  None to treat the whole file as in scope. When provided, a finding
247
- blocks only when its block-anchor line is among the changed lines.
247
+ blocks only when any line of its block span is among the changed
248
+ lines.
248
249
  defer_scope_to_caller: When True, return every finding so the commit/push
249
250
  gate's ``Line N:`` partitioning scopes by added line.
250
251
 
@@ -813,9 +814,9 @@ def _naive_datetime_remediation(constructor_name: str) -> str:
813
814
  """Return the fix guidance for a specific naive datetime constructor.
814
815
 
815
816
  ``utcnow`` and ``utcfromtimestamp`` accept no ``tz`` argument, so each points
816
- at its aware replacement (``datetime.now(tz=...)`` and
817
- ``datetime.fromtimestamp(..., tz=...)``); ``fromtimestamp`` keeps its shape and
818
- only needs a ``tz=`` argument.
817
+ at its aware replacement (``now(tz=...)`` and ``fromtimestamp(..., tz=...)``
818
+ on the same datetime class, whichever import form the call used);
819
+ ``fromtimestamp`` keeps its shape and only needs a ``tz=`` argument.
819
820
 
820
821
  Args:
821
822
  constructor_name: The naive constructor the call used.
@@ -824,9 +825,12 @@ def _naive_datetime_remediation(constructor_name: str) -> str:
824
825
  The remediation clause to embed in the emitted issue.
825
826
  """
826
827
  if constructor_name == NAIVE_DATETIME_UTCNOW_CONSTRUCTOR:
827
- return "use datetime.now(tz=timezone.utc)"
828
+ return "replace utcnow() with now(tz=timezone.utc) on the same datetime class"
828
829
  if constructor_name == NAIVE_DATETIME_UTCFROMTIMESTAMP_CONSTRUCTOR:
829
- return "use datetime.fromtimestamp(..., tz=timezone.utc)"
830
+ return (
831
+ "replace utcfromtimestamp(...) with fromtimestamp(..., tz=timezone.utc) "
832
+ "on the same datetime class"
833
+ )
830
834
  return "pass tz= (e.g. tz=timezone.utc)"
831
835
 
832
836
 
@@ -17,6 +17,7 @@ from code_rules_path_utils import ( # noqa: E402
17
17
  )
18
18
  from code_rules_shared import ( # noqa: E402
19
19
  _extract_fstring_literal_parts,
20
+ docstring_line_numbers,
20
21
  is_test_file,
21
22
  )
22
23
 
@@ -156,6 +157,7 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
156
157
 
157
158
  issues = []
158
159
  lines = content.split("\n")
160
+ all_docstring_line_numbers = docstring_line_numbers(content)
159
161
  is_inside_function = False
160
162
 
161
163
  number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
@@ -167,6 +169,9 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
167
169
  if not stripped:
168
170
  continue
169
171
 
172
+ if each_line_number in all_docstring_line_numbers:
173
+ continue
174
+
170
175
  if re.match(r"^(async\s+)?def\s+\w+", stripped):
171
176
  is_inside_function = True
172
177
  continue
@@ -434,14 +434,21 @@ def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[st
434
434
 
435
435
 
436
436
  def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
437
- """Flag a boolean assignment whose target and callee assert opposite polarity.
438
-
439
- Catches the self-contradicting statement ``is_inside_allowed =
440
- _point_hits_any_forbidden(...)`` — a target name carrying one polarity token
441
- (``allowed``) assigned directly from a single call whose callee name carries
442
- the antonym token (``forbidden``). One side lies about the behavior, so the
443
- reader cannot trust either name. Rename the callee to a neutral form that
444
- reads truthfully at every call site.
437
+ """Flag an assignment whose target and callee assert opposite polarity.
438
+
439
+ ::
440
+
441
+ is_inside_allowed = _point_hits_any_forbidden(...)
442
+ ^^^^^^^ ^^^^^^^^^
443
+ allowed vs. forbidden ⚠ the two names clash
444
+ ok: is_inside_allowed = _point_inside_allowed_region(...)
445
+ flag: is_inside_allowed = _point_hits_any_forbidden(...)
446
+
447
+ The target token and the callee token contradict each other, so the reader
448
+ cannot tell which name states the truth. Any single-name assignment from a
449
+ call is inspected — collections and counts included, not only booleans.
450
+ Rename the callee to a neutral form the two names agree on at every call
451
+ site.
445
452
  """
446
453
  if is_test_file(file_path):
447
454
  return []
@@ -164,8 +164,9 @@ def _stem_matched_test_path(module_path: Path) -> Path | None:
164
164
 
165
165
  Returns:
166
166
  The path of the first existing ``test_<stem>.py`` or ``<stem>_test.py``
167
- file beside the module or under an ancestor ``tests`` directory — or
168
- None when the module has no dedicated test file.
167
+ file beside the module, or ``test_<stem>.py`` under an ancestor
168
+ ``tests`` directory — or None when the module has no dedicated test
169
+ file.
169
170
  """
170
171
  module_directory = module_path.parent
171
172
  module_stem = module_path.stem
@@ -273,6 +273,40 @@ def is_migration_file(file_path: str) -> bool:
273
273
  return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_MIGRATION_PATH_PATTERNS)
274
274
 
275
275
 
276
+ def docstring_line_numbers(content: str) -> set[int]:
277
+ """Return every source line that sits inside a string-literal statement.
278
+
279
+ A diagram-first docstring often draws its point across several lines, and
280
+ those lines can hold a bare number (a row marker) or a contrast row
281
+ (``flag: is_ok = do_thing(...)``). Those are prose, not code, so a
282
+ line-based lint check that reads them as literals fires a false positive.
283
+ This walks the parsed source and collects the line numbers spanned by every
284
+ string-literal expression statement — module, class, and function
285
+ docstrings, plus any bare string statement — so those checks can skip them.
286
+ Unparseable source yields an empty set.
287
+
288
+ Args:
289
+ content: The Python source to scan.
290
+
291
+ Returns:
292
+ The 1-indexed line numbers that fall inside a string-literal statement.
293
+ """
294
+ try:
295
+ parsed_tree = ast.parse(content)
296
+ except SyntaxError:
297
+ return set()
298
+ all_docstring_line_numbers: set[int] = set()
299
+ for each_node in ast.walk(parsed_tree):
300
+ if not (isinstance(each_node, ast.Expr) and _statement_is_docstring(each_node)):
301
+ continue
302
+ end_line_number = each_node.end_lineno
303
+ if end_line_number is None:
304
+ continue
305
+ for each_line_number in range(each_node.lineno, end_line_number + 1):
306
+ all_docstring_line_numbers.add(each_line_number)
307
+ return all_docstring_line_numbers
308
+
309
+
276
310
  def _build_parent_map(module_tree: ast.Module) -> dict[int, ast.AST]:
277
311
  """Map child node id() to its parent node for ancestor walking."""
278
312
  parent_by_child_id: dict[int, ast.AST] = {}
@@ -1,4 +1,4 @@
1
- """Bare string-literal magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
1
+ """Bare string-literal magic, join-separator string magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
2
2
 
3
3
  import ast
4
4
  import re
@@ -1,4 +1,4 @@
1
- """Unused module-level import check and its import-range and type-checking-gate helpers."""
1
+ """Unused module-level import check and its import-range helpers."""
2
2
 
3
3
  import ast
4
4
  import sys
@@ -143,7 +143,7 @@ def check_unused_module_level_imports(
143
143
 
144
144
  When ``full_file_content`` is provided, ``content`` is treated as an Edit
145
145
  fragment containing the imports being added or replaced, while the
146
- ``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
146
+ ``__all__`` gate detection and reference scanning run
147
147
  against ``full_file_content`` (the post-edit file as it will look once the
148
148
  Edit applies). This prevents false-positive flags on imports added in the
149
149
  same Edit as their consumers.
@@ -72,10 +72,10 @@ def path_is_exempt(file_path: str) -> bool:
72
72
  Returns:
73
73
  True when the path's basename is the canonical shared-helper home, an
74
74
  rmtree-blocker hook source, one of the existing in-repo definition sites
75
- (session_env_cleanup.py, _md_to_html_blocker_test_support.py,
76
- teardown_worktrees.py), or a test file. A definition there is intentional.
77
- Basename equality (not substring containment) prevents a sibling whose name
78
- merely contains an exempt fragment from bypassing the block.
75
+ (session_env_cleanup.py, teardown_worktrees.py), or a test file. A
76
+ definition there is intentional. Basename equality (not substring
77
+ containment) prevents a sibling whose name merely contains an exempt
78
+ fragment from bypassing the block.
79
79
  """
80
80
  normalized_path = file_path.replace("\\", "/")
81
81
  file_name = normalized_path.rsplit("/", 1)[-1]
@@ -75,6 +75,35 @@ def test_flags_anchor_line_scope_against_span_intersection_body() -> None:
75
75
  assert "all_changed_lines" in issues[0]
76
76
 
77
77
 
78
+ def test_flags_single_line_scope_against_stepped_range_span_body() -> None:
79
+ content = (
80
+ "def check_import_block_sorted(\n"
81
+ " content: str, file_path: str, all_changed_lines: set[int] | None\n"
82
+ ") -> list[str]:\n"
83
+ ' """Flag an unsorted import block scoped to the changed lines.\n'
84
+ "\n"
85
+ " Args:\n"
86
+ " content: The full file content the write would leave on disk.\n"
87
+ " file_path: The destination path used to gate by extension.\n"
88
+ " all_changed_lines: Post-edit line numbers, or None. When provided,\n"
89
+ " a finding blocks only when its block-anchor line is among the\n"
90
+ " changed lines.\n"
91
+ "\n"
92
+ " Returns:\n"
93
+ " One issue string per detected finding.\n"
94
+ ' """\n'
95
+ " span_range = range(line_number, block_end_line_number + 1, 1)\n"
96
+ " all_violations.append((span_range, message))\n"
97
+ " return _scope_violations_to_changed_lines(\n"
98
+ " all_violations, all_changed_lines, defer_scope_to_caller\n"
99
+ " )\n"
100
+ )
101
+ issues = check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH)
102
+ assert len(issues) == 1
103
+ assert "check_import_block_sorted" in issues[0]
104
+ assert "all_changed_lines" in issues[0]
105
+
106
+
78
107
  def test_flags_the_line_is_among_phrasing() -> None:
79
108
  content = (
80
109
  "def check_block(\n"