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.
- package/_shared/pr-loop/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +0 -4
- package/hooks/blocking/code_rules_constants_config.py +5 -0
- package/hooks/blocking/code_rules_docstrings.py +116 -6
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +11 -6
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/hooks_constants/CLAUDE.md +0 -3
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -1
- package/rules/plain-illustrative-docstrings.md +35 -1
- package/skills/CLAUDE.md +6 -1
- package/skills/autoconverge/SKILL.md +38 -57
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +70 -73
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +72 -59
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +15 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
package/bin/install.test.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
891
|
-
assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size,
|
|
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
|
|
package/commands/CLAUDE.md
CHANGED
|
@@ -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) |
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -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.
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -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
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
2572
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|