claude-dev-env 1.83.0 → 1.84.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 (165) 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/reviews_disabled.py +2 -0
  5. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  6. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  7. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  9. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  10. package/agents/clean-coder.md +3 -0
  11. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  12. package/bin/install.mjs +16 -8
  13. package/bin/install.test.mjs +17 -6
  14. package/commands/CLAUDE.md +0 -1
  15. package/docs/CODE_RULES.md +1 -1
  16. package/hooks/blocking/CLAUDE.md +0 -4
  17. package/hooks/blocking/code_rules_constants_config.py +5 -0
  18. package/hooks/blocking/code_rules_docstrings.py +116 -6
  19. package/hooks/blocking/code_rules_enforcer.py +4 -0
  20. package/hooks/blocking/code_rules_magic_values.py +5 -0
  21. package/hooks/blocking/code_rules_naming_collection.py +11 -6
  22. package/hooks/blocking/code_rules_shared.py +34 -0
  23. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  25. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  26. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  27. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  28. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  29. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  30. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  31. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  32. package/hooks/hooks_constants/CLAUDE.md +0 -3
  33. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  35. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  37. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  38. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  39. package/hooks/workflow/CLAUDE.md +2 -8
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -1
  42. package/rules/plain-illustrative-docstrings.md +35 -1
  43. package/skills/CLAUDE.md +6 -1
  44. package/skills/autoconverge/SKILL.md +38 -57
  45. package/skills/autoconverge/reference/closing-report.md +6 -6
  46. package/skills/autoconverge/reference/convergence.md +17 -15
  47. package/skills/autoconverge/reference/gotchas.md +6 -3
  48. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  49. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  50. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  51. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  52. package/skills/autoconverge/workflow/converge.mjs +70 -73
  53. package/skills/autoconverge/workflow/render_report.py +7 -11
  54. package/skills/bugteam/CLAUDE.md +1 -1
  55. package/skills/bugteam/PROMPTS.md +7 -6
  56. package/skills/bugteam/SKILL.md +25 -72
  57. package/skills/bugteam/reference/CLAUDE.md +1 -3
  58. package/skills/bugteam/reference/README.md +1 -1
  59. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  60. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  61. package/skills/bugteam/reference/team-setup.md +8 -7
  62. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  63. package/skills/bugteam/scripts/README.md +0 -4
  64. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  65. package/skills/code/SKILL.md +2 -0
  66. package/skills/copilot-review/CLAUDE.md +1 -1
  67. package/skills/copilot-review/SKILL.md +25 -23
  68. package/skills/findbugs/CLAUDE.md +2 -2
  69. package/skills/findbugs/SKILL.md +22 -83
  70. package/skills/fixbugs/SKILL.md +2 -4
  71. package/skills/log-audit/CLAUDE.md +20 -0
  72. package/skills/log-audit/SKILL.md +68 -0
  73. package/skills/log-audit/reference/CLAUDE.md +9 -0
  74. package/skills/log-audit/reference/charter.md +52 -0
  75. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  76. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  77. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  78. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  79. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  80. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  81. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  82. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  83. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  84. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  85. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  86. package/skills/monitor-open-prs/SKILL.md +2 -2
  87. package/skills/post-audit-findings/SKILL.md +84 -0
  88. package/skills/pr-converge/CLAUDE.md +2 -0
  89. package/skills/pr-converge/SKILL.md +72 -59
  90. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  91. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  92. package/skills/pr-converge/reference/examples.md +5 -5
  93. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  94. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  95. package/skills/pr-converge/reference/per-tick.md +24 -45
  96. package/skills/pr-converge/reference/state-schema.md +15 -0
  97. package/skills/pr-converge/scripts/README.md +3 -5
  98. package/skills/pr-fix-protocol/SKILL.md +70 -0
  99. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  100. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  101. package/skills/pr-scope-resolve/SKILL.md +48 -0
  102. package/skills/qbug/CLAUDE.md +4 -4
  103. package/skills/qbug/SKILL.md +46 -144
  104. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  105. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  106. package/skills/reviewer-gates/SKILL.md +96 -0
  107. package/skills/session-log/CLAUDE.md +7 -7
  108. package/skills/session-log/SKILL.md +27 -44
  109. package/skills/test_markdown_link_integrity.py +103 -0
  110. package/commands/doc-gist.md +0 -16
  111. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  112. package/hooks/blocking/conftest.py +0 -30
  113. package/hooks/blocking/md_path_exemptions.py +0 -224
  114. package/hooks/blocking/md_to_html_blocker.py +0 -155
  115. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  116. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  117. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  118. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  119. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  120. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  121. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  122. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  123. package/hooks/workflow/md_to_html_companion.py +0 -358
  124. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  125. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  126. package/skills/bugteam/reference/audit-contract.md +0 -163
  127. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  128. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  129. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  131. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  133. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  134. package/skills/doc-gist/CLAUDE.md +0 -25
  135. package/skills/doc-gist/SKILL.md +0 -97
  136. package/skills/doc-gist/references/CLAUDE.md +0 -9
  137. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  138. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  139. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  140. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  141. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  142. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  143. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  144. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  145. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  146. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  147. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  148. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  149. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  150. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  151. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  152. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  153. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  154. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  155. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  156. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  157. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  158. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  159. package/skills/doc-gist/references/examples/README.md +0 -5
  160. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  161. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  162. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  163. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  164. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  165. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -718,12 +718,13 @@ const OLD_FOLDED_HOOKS_SETTINGS = {
718
718
  };
719
719
 
720
720
 
721
- test('FOLDED_HOOK_RELATIVE_PATHS contains all 16 hooks removed from hooks.json', () => {
722
- assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 16);
721
+ test('FOLDED_HOOK_RELATIVE_PATHS contains all 16 hooks removed from hooks.json plus the retired md_to_html_blocker', () => {
722
+ assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 17);
723
723
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
724
724
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/plain_language_blocker.py'));
725
725
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
726
726
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
727
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/md_to_html_blocker.py'));
727
728
  });
728
729
 
729
730
 
@@ -746,16 +747,25 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
746
747
  'blocking/open_questions_in_plans_blocker.py',
747
748
  'blocking/plain_language_blocker.py',
748
749
  ];
750
+ const retiredHooks = [
751
+ 'blocking/md_to_html_blocker.py',
752
+ ];
749
753
  for (const hostedPath of dispatcherHostedHooks) {
750
754
  assert.ok(
751
755
  FOLDED_HOOK_RELATIVE_PATHS.has(hostedPath),
752
756
  `dispatcher-hosted hook ${hostedPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry and it does not double-run`
753
757
  );
754
758
  }
759
+ for (const retiredPath of retiredHooks) {
760
+ assert.ok(
761
+ FOLDED_HOOK_RELATIVE_PATHS.has(retiredPath),
762
+ `retired hook ${retiredPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry pointing at a script no longer on disk`
763
+ );
764
+ }
755
765
  assert.equal(
756
766
  FOLDED_HOOK_RELATIVE_PATHS.size,
757
- dispatcherHostedHooks.length,
758
- 'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks, no more, no fewer'
767
+ dispatcherHostedHooks.length + retiredHooks.length,
768
+ 'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks plus the retired hooks, no more, no fewer'
759
769
  );
760
770
  });
761
771
 
@@ -887,11 +897,12 @@ const OLD_POST_FOLDED_HOOKS_SETTINGS = {
887
897
  };
888
898
 
889
899
 
890
- test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the three after-write hooks folded into the PostToolUse dispatcher', () => {
891
- assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 3);
900
+ test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the after-write hooks and the retired md_to_html_companion', () => {
901
+ assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 4);
892
902
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
893
903
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
894
904
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
905
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/md_to_html_companion.py'));
895
906
  });
896
907
 
897
908
 
@@ -7,7 +7,6 @@ Slash-command definitions installed into `~/.claude/commands/` by `bin/install.m
7
7
  | File | Command | What it does |
8
8
  |---|---|---|
9
9
  | `commit.md` | `/commit` | Commits and pushes changes to GitHub |
10
- | `doc-gist.md` | `/doc-gist` | Uploads an HTML file as a secret gist and opens the htmlpreview URL |
11
10
  | `docupdate.md` | `/docupdate` | Updates documentation to match current code state |
12
11
  | `hook-log-extract.md` | `/hook-log-extract` | Extracts and formats hook log entries for a session |
13
12
  | `hook-log-init.md` | `/hook-log-init` | Initializes the Neon Postgres schema that backs the hook-log extractor (one-time per machine) |
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
94
94
 
95
95
  ## 11. ENFORCEMENT SURFACES
96
96
 
97
- ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
97
+ ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit. The diagram-first docstring standard (a summary line, then a `::` example or doctest, then a couple of short prose lines) lives in `packages/claude-dev-env/rules/plain-illustrative-docstrings.md`, enforced by the `check_docstring_runon_sentence` and `check_docstring_prose_wall_without_illustration` backstop hooks and Category O9 audit.
@@ -77,7 +77,6 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
77
77
  | `hedging_language_blocker.py` | Stop | Responses with hedging words (`likely`, `probably`, `appears to`) |
78
78
  | `hook_prose_detector_consistency.py` | PreToolUse (Write/Edit) | Hook docstrings/messages that claim a trigger the detector cannot fire on |
79
79
  | `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
80
- | `md_to_html_blocker.py` | PreToolUse (Write/Edit) | Writing `.md` files when an `.html` companion is required |
81
80
  | `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
82
81
  | `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
83
82
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
@@ -107,8 +106,6 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
107
106
  | File | Role |
108
107
  |---|---|
109
108
  | `_gh_body_arg_utils.py` | Parsing helpers for `gh_body_arg_blocker.py` |
110
- | `_md_to_html_blocker_test_support.py` | Test fixtures shared across `md_to_html_blocker` tests |
111
- | `md_path_exemptions.py` | Path exemption logic for `md_to_html_blocker.py` |
112
109
  | `pr_description_body_audit.py` | Body audit logic for `pr_description_enforcer.py` |
113
110
  | `pr_description_command_parser.py` | `gh` command parsing for `pr_description_enforcer.py` |
114
111
  | `pr_description_pr_number.py` | PR number extraction logic |
@@ -119,4 +116,3 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
119
116
 
120
117
  - Tests live beside each hook as `test_<hookname>.py` or `test_<hookname>_<suffix>.py`. Run with `python -m pytest <test_file>`.
121
118
  - Tunable constants live in `hooks_constants/<hook_name>_constants.py`; the verified-commit family uses `blocking/config/verified_commit_constants.py`.
122
- - `conftest.py` gives shared pytest fixtures for the blocking test suite.
@@ -23,6 +23,7 @@ from code_rules_path_utils import ( # noqa: E402
23
23
  )
24
24
  from code_rules_shared import ( # noqa: E402
25
25
  _build_parent_map,
26
+ docstring_line_numbers,
26
27
  get_file_extension,
27
28
  is_migration_file,
28
29
  is_test_file,
@@ -110,6 +111,7 @@ def _scan_function_body_constants(content: str) -> list[str]:
110
111
  """
111
112
  advisory_issues: list[str] = []
112
113
  lines = content.split("\n")
114
+ all_docstring_line_numbers = docstring_line_numbers(content)
113
115
  function_indent_stack: list[int] = []
114
116
  constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
115
117
 
@@ -119,6 +121,9 @@ def _scan_function_body_constants(content: str) -> list[str]:
119
121
  if not stripped:
120
122
  continue
121
123
 
124
+ if each_line_number in all_docstring_line_numbers:
125
+ continue
126
+
122
127
  indent = len(each_line) - len(each_line.lstrip())
123
128
 
124
129
  while function_indent_stack and indent <= function_indent_stack[-1] and not stripped.startswith(("#", "@", ")")):
@@ -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,
@@ -2562,14 +2565,71 @@ def _is_narrative_cut_header(stripped_line: str) -> bool:
2562
2565
  )
2563
2566
 
2564
2567
 
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()
2568
+ def _docstring_narrative_partition(docstring_text: str) -> tuple[list[str], bool]:
2569
+ """Split a docstring narrative into its prose lines and whether it shows an example.
2570
+
2571
+ Walk the narrative up to the first ``Args:``/``Returns:`` cut header and sort
2572
+ every line into one of two buckets::
2573
+
2574
+ Render the report so the reader sees how it ended. <- prose line
2575
+ A finished run versus an interrupted one:: <- prose line (opener)
2576
+ theme_42 -- interrupted -> marked in-flight -- indented example body
2577
+ OK: a clean run reads differently in the log -- indented example body
2578
+ The reader reads the final outcome at a glance. <- prose line
2579
+
2580
+ A ``>>>`` doctest region and a ``::`` block with an indented body both count as
2581
+ an illustration, so their lines never join the prose bucket. The caller reads
2582
+ the prose count to size the wall and the flag to know whether an example is present.
2583
+ """
2584
+ prose_lines: list[str] = []
2585
+ has_illustration = False
2586
+ all_lines = docstring_text.splitlines()
2587
+ total_lines = len(all_lines)
2588
+ line_index = 0
2589
+ while line_index < total_lines:
2590
+ raw_line = all_lines[line_index]
2591
+ stripped_line = raw_line.strip()
2569
2592
  if _is_narrative_cut_header(stripped_line):
2570
2593
  break
2571
- narrative_lines.append(stripped_line)
2572
- return " ".join(narrative_lines)
2594
+ if stripped_line.startswith(">>>"):
2595
+ has_illustration = True
2596
+ line_index += 1
2597
+ while line_index < total_lines:
2598
+ inner_stripped = all_lines[line_index].strip()
2599
+ if not inner_stripped or _is_narrative_cut_header(inner_stripped):
2600
+ break
2601
+ line_index += 1
2602
+ continue
2603
+ if stripped_line.endswith("::"):
2604
+ opener_indent = len(raw_line) - len(raw_line.lstrip())
2605
+ probe_index = line_index + 1
2606
+ has_indented_body = False
2607
+ while probe_index < total_lines:
2608
+ probe_raw = all_lines[probe_index]
2609
+ probe_stripped = probe_raw.strip()
2610
+ if not probe_stripped:
2611
+ probe_index += 1
2612
+ continue
2613
+ probe_indent = len(probe_raw) - len(probe_raw.lstrip())
2614
+ if probe_indent > opener_indent:
2615
+ has_indented_body = True
2616
+ probe_index += 1
2617
+ continue
2618
+ break
2619
+ if has_indented_body:
2620
+ has_illustration = True
2621
+ prose_lines.append(stripped_line)
2622
+ line_index = probe_index
2623
+ continue
2624
+ if stripped_line:
2625
+ prose_lines.append(stripped_line)
2626
+ line_index += 1
2627
+ return prose_lines, has_illustration
2628
+
2629
+
2630
+ def _docstring_narrative_text(docstring_text: str) -> str:
2631
+ prose_lines, _has_illustration = _docstring_narrative_partition(docstring_text)
2632
+ return DOCSTRING_NARRATIVE_LINE_JOIN_SEPARATOR.join(prose_lines)
2573
2633
 
2574
2634
 
2575
2635
  def _sentence_word_count(sentence_text: str) -> int:
@@ -2675,6 +2735,56 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
2675
2735
  return issues[:MAX_DOCSTRING_RUNON_SENTENCE_ISSUES]
2676
2736
 
2677
2737
 
2738
+ def check_docstring_prose_wall_without_illustration(content: str, file_path: str) -> list[str]:
2739
+ """Flag a summary that tells for many sentences and shows nothing.
2740
+
2741
+ A reader trusts the opening to paint a scene. A run of short sentences with
2742
+ no worked example leaves the reader piecing that scene together alone::
2743
+
2744
+ A calm voyage ends well for every vessel. <- one more line
2745
+ A halted voyage marks the vessel it neared. <- and another
2746
+ ... more like these, no worked example ... flag: a wall, no scene
2747
+ A calm voyage versus a halted one:: ok: a worked example
2748
+ a lone vessel -- halted -> marked mid-voyage
2749
+
2750
+ Past the prose-line limit with no ``::`` listing and no ``>>>`` doctest, this
2751
+ fires. A narrative that shows a worked example, or one at the limit, passes.
2752
+
2753
+ Args:
2754
+ content: The source text to inspect.
2755
+ file_path: The path the source will be written to, used for exemptions.
2756
+
2757
+ Returns:
2758
+ One issue per summary that runs a wall of sentences with no worked
2759
+ example, capped at the issue limit for the rule.
2760
+ """
2761
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
2762
+ return []
2763
+ try:
2764
+ parsed_tree = ast.parse(content)
2765
+ except SyntaxError:
2766
+ return []
2767
+ issues: list[str] = []
2768
+ for each_line_number, each_label, each_docstring in _documentable_docstring_targets(
2769
+ parsed_tree
2770
+ ):
2771
+ prose_lines, has_illustration = _docstring_narrative_partition(each_docstring)
2772
+ if has_illustration:
2773
+ continue
2774
+ prose_line_count = len(prose_lines)
2775
+ if prose_line_count <= DOCSTRING_NARRATIVE_PROSE_LINE_LIMIT:
2776
+ continue
2777
+ issues.append(
2778
+ f"Line {each_line_number}: {each_label} summary runs {prose_line_count} "
2779
+ "narrative lines with no worked example - show, don't tell: swap the wall for a "
2780
+ "'::' listing (a sample input, an annotated outcome, ok/flag contrast rows) and "
2781
+ "keep the narrative to a few short lines (plain-illustrative-docstrings)"
2782
+ )
2783
+ if len(issues) >= MAX_DOCSTRING_PROSE_WALL_ISSUES:
2784
+ break
2785
+ return issues[:MAX_DOCSTRING_PROSE_WALL_ISSUES]
2786
+
2787
+
2678
2788
  def _raises_section_text(docstring_text: str) -> str:
2679
2789
  docstring_lines = docstring_text.splitlines()
2680
2790
  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
  )
@@ -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
@@ -436,12 +436,17 @@ def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[st
436
436
  def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
437
437
  """Flag a boolean assignment whose target and callee assert opposite polarity.
438
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.
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. Rename the callee to a neutral form
449
+ the two names agree on at every call site.
445
450
  """
446
451
  if is_test_file(file_path):
447
452
  return []
@@ -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] = {}
@@ -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]
@@ -0,0 +1,197 @@
1
+ """Behavioural checks for the show-don't-tell narrative gate.
2
+
3
+ A readable summary earns its place by painting a concrete scene: a sample
4
+ input, an annotated outcome, an OK/FLAG contrast a reader pictures at once. A
5
+ summary that runs many short sentences with no worked example tells without
6
+ showing. The gate flags that wall in a top-level, class, or callable summary,
7
+ and stays quiet the moment the narrative carries a '::' listing or a '>>>'
8
+ doctest.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ import sys
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+
18
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
19
+ if _HOOKS_DIRECTORY not in sys.path:
20
+ sys.path.insert(0, _HOOKS_DIRECTORY)
21
+
22
+
23
+ def _load_enforcer_module() -> ModuleType:
24
+ enforcer_module_name = "code_rules_enforcer"
25
+ enforcer_path = Path(__file__).parent / (enforcer_module_name + ".py")
26
+ spec = importlib.util.spec_from_file_location(enforcer_module_name, enforcer_path)
27
+ assert spec is not None
28
+ assert spec.loader is not None
29
+ loaded_module = importlib.util.module_from_spec(spec)
30
+ spec.loader.exec_module(loaded_module)
31
+ return loaded_module
32
+
33
+
34
+ code_rules_enforcer = _load_enforcer_module()
35
+
36
+
37
+ def check_docstring_prose_wall_without_illustration(content: str, file_path: str) -> list[str]:
38
+ return code_rules_enforcer.check_docstring_prose_wall_without_illustration(content, file_path)
39
+
40
+
41
+ def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
42
+ return code_rules_enforcer.validate_content(content, file_path, old_content)
43
+
44
+
45
+ PRODUCTION_FILE_PATH = "/project/src/tally.py"
46
+ SUITE_FILE_PATH = "/project/src/test_tally.py"
47
+ INFRA_PATH = "/home/user/.claude/hooks/blocking/example.py"
48
+
49
+
50
+ def _fixture_top_level_wall() -> str:
51
+ return (
52
+ '"""Assemble the nightly voyage tally from the harbor scans.\n'
53
+ "\n"
54
+ "A scan names one vessel and where it dropped anchor.\n"
55
+ "The tally walks the scans in arrival order and keeps that order.\n"
56
+ "A calm voyage ends well for every vessel it carried.\n"
57
+ "A halted voyage marks the vessel it was near when the storm arrived.\n"
58
+ "A wrecked voyage marks the vessel that sank and stops the walk there.\n"
59
+ "The tally groups the vessels by their final port for the harbor.\n"
60
+ "The harbor reads the tally and sees every arrival at a glance.\n"
61
+ '"""\n'
62
+ "\n"
63
+ "def emit_tally() -> None:\n"
64
+ " return None\n"
65
+ )
66
+
67
+
68
+ def _fixture_callable_wall() -> str:
69
+ return (
70
+ "def emit_tally() -> None:\n"
71
+ ' """Post the voyage tally to the harbor board.\n'
72
+ "\n"
73
+ " A scan names one vessel and where it dropped anchor.\n"
74
+ " The tally walks the scans in arrival order and keeps that order.\n"
75
+ " A calm voyage ends well for every vessel it carried.\n"
76
+ " A halted voyage marks the vessel it was near when the storm arrived.\n"
77
+ " A wrecked voyage marks the vessel that sank and stops the walk there.\n"
78
+ " The tally groups the vessels by their final port for the reader.\n"
79
+ " The reader reads the whole tally at a single glance.\n"
80
+ ' """\n'
81
+ " return None\n"
82
+ )
83
+
84
+
85
+ def _fixture_wall_with_listing() -> str:
86
+ return (
87
+ "def emit_tally() -> None:\n"
88
+ ' """Post the voyage tally to the harbor board.\n'
89
+ "\n"
90
+ " A scan names one vessel and where it dropped anchor::\n"
91
+ "\n"
92
+ " vessel 42 -- halted -> marked mid-voyage\n"
93
+ " OK: a calm voyage ends well for every vessel\n"
94
+ " FLAG: a wrecked voyage reads the same as a calm one\n"
95
+ "\n"
96
+ " The reader reads the whole tally at a single glance.\n"
97
+ ' """\n'
98
+ " return None\n"
99
+ )
100
+
101
+
102
+ def _fixture_wall_with_doctest() -> str:
103
+ return (
104
+ "def emit_tally() -> None:\n"
105
+ ' """Post the voyage tally to the harbor board.\n'
106
+ "\n"
107
+ " A calm voyage ends well for every vessel it carried.\n"
108
+ " A halted voyage marks the vessel it was near when the storm arrived.\n"
109
+ " A wrecked voyage marks the vessel that sank and stops the walk there.\n"
110
+ "\n"
111
+ " >>> emit_tally()\n"
112
+ " vessel 42 halted\n"
113
+ " vessel 43 calm\n"
114
+ "\n"
115
+ " The reader reads the whole tally at a single glance.\n"
116
+ ' """\n'
117
+ " return None\n"
118
+ )
119
+
120
+
121
+ def _fixture_short_summary() -> str:
122
+ return (
123
+ "def emit_tally() -> None:\n"
124
+ ' """Post the voyage tally to the harbor board.\n'
125
+ "\n"
126
+ " A scan names one vessel and where it dropped anchor.\n"
127
+ " A calm voyage ends well for every vessel it carried.\n"
128
+ " A halted voyage marks the vessel it was near when it stopped.\n"
129
+ " The reader reads the whole tally at a single glance.\n"
130
+ ' """\n'
131
+ " return None\n"
132
+ )
133
+
134
+
135
+ def test_should_flag_prose_wall_with_no_illustration() -> None:
136
+ issues = check_docstring_prose_wall_without_illustration(
137
+ _fixture_top_level_wall(), PRODUCTION_FILE_PATH
138
+ )
139
+ assert any("worked example" in each for each in issues), (
140
+ f"A top-level wall must flag, got: {issues!r}"
141
+ )
142
+ assert any("module" in each for each in issues)
143
+ assert len(issues) == 1
144
+
145
+
146
+ def test_should_flag_function_prose_wall() -> None:
147
+ issues = check_docstring_prose_wall_without_illustration(
148
+ _fixture_callable_wall(), PRODUCTION_FILE_PATH
149
+ )
150
+ assert any("emit_tally" in each for each in issues), (
151
+ f"A callable wall must flag, got: {issues!r}"
152
+ )
153
+ assert len(issues) == 1
154
+
155
+
156
+ def test_should_not_flag_wall_with_literal_block() -> None:
157
+ issues = check_docstring_prose_wall_without_illustration(
158
+ _fixture_wall_with_listing(), PRODUCTION_FILE_PATH
159
+ )
160
+ assert issues == [], f"A '::' listing must pass, got: {issues!r}"
161
+
162
+
163
+ def test_should_not_flag_wall_with_doctest() -> None:
164
+ issues = check_docstring_prose_wall_without_illustration(
165
+ _fixture_wall_with_doctest(), PRODUCTION_FILE_PATH
166
+ )
167
+ assert issues == [], f"A '>>>' doctest must pass, got: {issues!r}"
168
+
169
+
170
+ def test_should_not_flag_short_narrative() -> None:
171
+ issues = check_docstring_prose_wall_without_illustration(
172
+ _fixture_short_summary(), PRODUCTION_FILE_PATH
173
+ )
174
+ assert issues == [], f"A summary at the sentence limit must pass, got: {issues!r}"
175
+
176
+
177
+ def test_should_skip_test_file() -> None:
178
+ issues = check_docstring_prose_wall_without_illustration(_fixture_top_level_wall(), SUITE_FILE_PATH)
179
+ assert issues == [], f"A suite path stays exempt, got: {issues!r}"
180
+
181
+
182
+ def test_should_skip_hook_infrastructure() -> None:
183
+ issues = check_docstring_prose_wall_without_illustration(
184
+ _fixture_top_level_wall(), INFRA_PATH
185
+ )
186
+ assert issues == [], f"An infra path stays exempt, got: {issues!r}"
187
+
188
+
189
+ def test_should_handle_syntax_error_gracefully() -> None:
190
+ issues = check_docstring_prose_wall_without_illustration("def broken(\n", PRODUCTION_FILE_PATH)
191
+ assert issues == [], f"A syntax error yields no issues, got: {issues!r}"
192
+
193
+
194
+ def test_validate_content_surfaces_prose_wall() -> None:
195
+ issues = validate_content(_fixture_top_level_wall(), PRODUCTION_FILE_PATH, old_content="")
196
+ matching_issues = [each for each in issues if "worked example" in each]
197
+ assert matching_issues, f"validate_content must surface the wall, got: {issues!r}"
@@ -252,6 +252,33 @@ def test_should_flag_spaced_double_hyphen_joiner() -> None:
252
252
  assert len(issues) == 1
253
253
 
254
254
 
255
+ def _diagram_first_literal_block() -> str:
256
+ return (
257
+ "def emit_tally() -> None:\n"
258
+ ' """Post the voyage tally so the reader sees how the run ended.\n'
259
+ "\n"
260
+ " A calm voyage versus a halted one::\n"
261
+ "\n"
262
+ " vessel 42 -- halted -> marked mid-voyage while it sailed\n"
263
+ " calm — every vessel it carried shows a clean recorded outcome\n"
264
+ " OK: a calm run and a sunk run read differently in the log\n"
265
+ " FLAG: a sunk run looks identical to a calm finished run here\n"
266
+ " The reader reads the final outcome at a glance.\n"
267
+ ' """\n'
268
+ " return None\n"
269
+ )
270
+
271
+
272
+ def test_should_not_flag_diagram_first_literal_block() -> None:
273
+ issues = check_docstring_runon_sentence(
274
+ _diagram_first_literal_block(), PRODUCTION_FILE_PATH
275
+ )
276
+ assert issues == [], (
277
+ "A '::' listing's arrow, em-dash, and OK/FLAG rows sit outside the "
278
+ f"narrative and must not inflate the run-on count, got: {issues!r}"
279
+ )
280
+
281
+
255
282
  def test_docstring_names_every_inspected_section_header() -> None:
256
283
  docstring = code_rules_enforcer.check_docstring_runon_sentence.__doc__
257
284
  assert docstring is not None