From 9b7453af91e9172ab170c81797cc215c120c4804 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 12:33:32 -0700 Subject: [PATCH 1/5] Drop a Comment the Comment-Added Gate Refuses Co-Authored-By: Claude Sonnet 5.5 --- .agents/skills/comment-and-doc-style/SKILL.md | 8 +- .../.source-digests/comment-and-doc-style | 2 +- .../skills/comment-and-doc-style/SKILL.md | 8 +- .github/actions/prose-gate/prose_lint.py | 187 ++++++++++++++---- .github/skills/comment-and-doc-style/SKILL.md | 8 +- tests/test_prose_lint.py | 91 ++++++++- 6 files changed, 254 insertions(+), 50 deletions(-) diff --git a/.agents/skills/comment-and-doc-style/SKILL.md b/.agents/skills/comment-and-doc-style/SKILL.md index 5f29c390e..aca382d08 100644 --- a/.agents/skills/comment-and-doc-style/SKILL.md +++ b/.agents/skills/comment-and-doc-style/SKILL.md @@ -128,10 +128,10 @@ restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` incrementally, and this section names each check as it ships. - **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by - the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, - because the existing corpus predates the cap and a default gate would fail whole files nobody - is editing. Write new prose under the cap, and scope a run to a change with - `--check sentence-length --diff `. + the `sentence-length` rule in `prose_lint.py`. The rule reads Markdown prose, code comments, and + docstrings. The check is opt-in like `sentence-split`, because the existing corpus predates the + cap and a default gate would fail whole files nobody is editing. Write new prose under the cap, + and scope a run to a change with `--check sentence-length --diff `. - **One instruction per sentence.** A procedure step states one action, and a second action is a second step. No pattern reaches this, so it is authoring discipline with no check. - **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate diff --git a/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style b/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style index 93806e89d..edb797b81 100644 --- a/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style +++ b/.claude-plugin/fleet-skills/.source-digests/comment-and-doc-style @@ -1 +1 @@ -bf9626afca871416 +5104f29713e7cc6c diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md index 5f29c390e..aca382d08 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md @@ -128,10 +128,10 @@ restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` incrementally, and this section names each check as it ships. - **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by - the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, - because the existing corpus predates the cap and a default gate would fail whole files nobody - is editing. Write new prose under the cap, and scope a run to a change with - `--check sentence-length --diff `. + the `sentence-length` rule in `prose_lint.py`. The rule reads Markdown prose, code comments, and + docstrings. The check is opt-in like `sentence-split`, because the existing corpus predates the + cap and a default gate would fail whole files nobody is editing. Write new prose under the cap, + and scope a run to a change with `--check sentence-length --diff `. - **One instruction per sentence.** A procedure step states one action, and a second action is a second step. No pattern reaches this, so it is authoring discipline with no check. - **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index ae4d23351..5e1824bc9 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -12,7 +12,7 @@ comment-added No comment line opening its own line added or edited, unless the change says so. dupword No duplicated consecutive word. sentence-split A sentence must not wrap across lines (one sentence per line). - sentence-length A Markdown prose sentence must not exceed the word cap. + sentence-length A Markdown, comment, or docstring sentence must not exceed the word cap. spelling No British spelling, the repo-wide convention being US English. home-path No absolute home path naming a real account, per the representative-data rule. dead-path No mention of a path git once tracked and the tree no longer holds. @@ -1809,6 +1809,47 @@ def quote(m: re.Match[str]) -> str: return "".join(out) +def over_cap_findings( + block: list[tuple[int, str]], allowed: set[int] | None = None +) -> list[tuple[int, str, str]]: + """Over-cap sentences in one block of `(line number, text)` pairs, each judged whole. + + The pairs are joined before the split, so a sentence wrapped across lines is counted once. + Both the Markdown reader and the comment reader hand their blocks here. + """ + if not block: + return [] + raw = "" + starts: list[int] = [] + for _, span in block: + if raw: + raw += " " + starts.append(len(raw)) + raw += span + numbers = [n for n, _ in block] + lengths = [len(span) for _, span in block] + text = mask_code_and_quotes(raw, starts, lengths) + out: list[tuple[int, str, str]] = [] + pos = 0 + for m in [*SENTENCE_BREAK.finditer(text), None]: + end = m.start() if m else len(text) + words = len(text[pos:end].split()) + if words > SENTENCE_WORD_CAP: + first = bisect_right(starts, pos) - 1 + last = bisect_right(starts, max(end - 1, pos)) - 1 + rows = numbers[first : last + 1] + hit = rows[0] if allowed is None else next((n for n in rows if n in allowed), 0) + if hit: + msg = ( + f"{words} words in one sentence -> " + f"sentences of {SENTENCE_WORD_CAP} words or fewer" + ) + out.append((hit, "sentence-length", msg)) + if m: + pos = m.end() + return out + + def sentence_length_findings( lines: list[str], allowed: set[int] | None = None ) -> list[tuple[int, str, str]]: @@ -1828,37 +1869,8 @@ def sentence_length_findings( def flush() -> None: nonlocal block_is_item block_is_item = False - if not block: - return - raw = "" - starts: list[int] = [] - for _, span in block: - if raw: - raw += " " - starts.append(len(raw)) - raw += span - numbers = [n for n, _ in block] - lengths = [len(span) for _, span in block] + out.extend(over_cap_findings(block, allowed)) block.clear() - text = mask_code_and_quotes(raw, starts, lengths) - pos = 0 - for m in [*SENTENCE_BREAK.finditer(text), None]: - end = m.start() if m else len(text) - piece = text[pos:end] - words = len(piece.split()) - if words > SENTENCE_WORD_CAP: - first = bisect_right(starts, pos) - 1 - last = bisect_right(starts, max(end - 1, pos)) - 1 - rows = numbers[first : last + 1] - hit = rows[0] if allowed is None else next((n for n in rows if n in allowed), 0) - if hit: - msg = ( - f"{words} words in one sentence -> " - f"sentences of {SENTENCE_WORD_CAP} words or fewer" - ) - out.append((hit, "sentence-length", msg)) - if m: - pos = m.end() start = front_matter_end(lines) for i, line in enumerate(lines[:start], 1): @@ -2635,6 +2647,110 @@ def issue_ref_findings( PARSED_RULES = frozenset({"comment-wrap", "comment-case", "comment-added", "issue-ref"}) +DOCSTRING_OPEN = re.compile(r"^\s*[rRuU]?(?:\"\"\"|\'\'\'|\"|\')") +DOCSTRING_CLOSE = re.compile(r"(?:\"\"\"|\'\'\'|\"|\')\s*$") +DOCTEST_PROMPT = re.compile(r"^\s*(?:>>>|\.\.\.)(?:\s|$)") + + +def comment_sentence_findings( + path: Path, raw: str, lines: list[str], allowed: set[int] | None = None +) -> list[tuple[int, str, str]]: + """Over-cap sentences in the comments of a source file, each judged whole. + + Comment lines on consecutive source lines are one block, so a wrapped sentence counts once. + A line that is not prose, a trailing comment, or a gap ends the block. + The prose filters are `comment_wrap_findings`', so the two rules agree on what a comment is. + """ + comments = python_comments(raw) if path.suffix.lower() == ".py" else None + if comments is None: + comments = extracted_comments(path, lines) + skip = fenced_lines(lines) + kept = [c for c in comments if c[0] not in skip] + comments = [c._replace(body=without_trailing_directive(path, c.body)) for c in kept] + configured = config_run_lines(comments) + out: list[tuple[int, str, str]] = [] + block: list[tuple[int, str]] = [] + prev_no = 0 + + def flush() -> None: + out.extend(over_cap_findings(block, allowed)) + block.clear() + + for n, body, leading, _raw in comments: + if ( + not body + or n in configured + or NOT_PROSE.search(body) + or BARE_URI.match(body.strip()) + or KEY_ONLY.match(body) + or (SNIPPET.match(body) and not SNIPPET_END.search(body)) + ): + flush() + continue + if not leading or n != prev_no + 1: + flush() + block.append((n, body.strip())) + prev_no = n + if not leading: + flush() + flush() + return out + + +def docstring_sentence_findings( + raw: str, lines: list[str], allowed: set[int] | None = None +) -> list[tuple[int, str, str]]: + """Over-cap sentences in Python docstrings, each judged whole. + + A blank docstring line ends a paragraph, as a blank line does in Markdown. + A doctest and the output under it, and an indented code block, are code and are not counted. + """ + spans = python_docstring_spans(raw, lines) + out: list[tuple[int, str, str]] = [] + numbers = sorted(spans) + groups: list[list[int]] = [] + for n in numbers: + if groups and n == groups[-1][-1] + 1: + groups[-1].append(n) + else: + groups.append([n]) + for group in groups: + texts = ["".join(spans[n]) for n in group] + texts[0] = DOCSTRING_OPEN.sub("", texts[0], count=1) + texts[-1] = DOCSTRING_CLOSE.sub("", texts[-1], count=1) + rest = [t for t in texts[1:] if t.strip()] + base = min((len(t) - len(t.lstrip()) for t in rest), default=0) + if len(texts) > 1 and texts[-1] and not texts[-1].strip(): + base = min(base, len(texts[-1])) if rest else len(texts[-1]) + block: list[tuple[int, str]] = [] + in_doctest = in_code = False + prev_blank = True + for idx, (n, text) in enumerate(zip(group, texts, strict=True)): + indent = len(text) - len(text.lstrip()) if idx else base + if not text.strip(): + out.extend(over_cap_findings(block, allowed)) + block.clear() + in_doctest = in_code = False + prev_blank = True + continue + if DOCTEST_PROMPT.match(text): + out.extend(over_cap_findings(block, allowed)) + block.clear() + in_doctest = True + elif in_doctest or (in_code and indent >= base + 4): + pass + elif prev_blank and indent >= base + 4: + out.extend(over_cap_findings(block, allowed)) + block.clear() + in_code = True + else: + in_code = False + block.append((n, text.strip())) + prev_blank = False + out.extend(over_cap_findings(block, allowed)) + return out + + def check_file( path: Path, rules: set[str], @@ -2696,8 +2812,13 @@ def check_file( if {"spelling", "dupword"} & rules and path.suffix != ".md": for ln, text, _leading, _raw in extracted_comments(path, lines): comments.setdefault(ln, []).append(text) - if "sentence-length" in rules and path.suffix == ".md": - out.extend(sentence_length_findings(lines, allowed)) + if "sentence-length" in rules: + if path.suffix == ".md": + out.extend(sentence_length_findings(lines, allowed)) + else: + out.extend(comment_sentence_findings(path, raw, lines, allowed)) + if path.suffix.lower() == ".py": + out.extend(docstring_sentence_findings(raw, lines, allowed)) in_fence = False prev_txt = "" prev_no = 0 diff --git a/.github/skills/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md index 5f29c390e..aca382d08 100644 --- a/.github/skills/comment-and-doc-style/SKILL.md +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -128,10 +128,10 @@ restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` incrementally, and this section names each check as it ships. - **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by - the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, - because the existing corpus predates the cap and a default gate would fail whole files nobody - is editing. Write new prose under the cap, and scope a run to a change with - `--check sentence-length --diff `. + the `sentence-length` rule in `prose_lint.py`. The rule reads Markdown prose, code comments, and + docstrings. The check is opt-in like `sentence-split`, because the existing corpus predates the + cap and a default gate would fail whole files nobody is editing. Write new prose under the cap, + and scope a run to a change with `--check sentence-length --diff `. - **One instruction per sentence.** A procedure step states one action, and a second action is a second step. No pattern reaches this, so it is authoring discipline with no check. - **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 4f1b4f357..4c54d7a0b 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2483,10 +2483,10 @@ def test_a_sentence_starting_mid_line_is_reported_on_that_line(self) -> None: tail = f"Done. {' '.join(['word'] * 20)}\n{' '.join(['word'] * 10)}.\n" self.assertEqual([(2, "sentence-length")], self.found("Intro line.\n" + tail)) - def test_the_rule_is_markdown_only(self) -> None: - """A source file's long lines are code, which no sentence rule judges.""" - over = self.sentence_of(prose_lint.SENTENCE_WORD_CAP + 1) - self.assertEqual([], self.kinds(f"# {over}\n", {"sentence-length"}, name="bait.py")) + def test_a_source_line_that_is_not_a_comment_is_not_judged(self) -> None: + """A source file's long code lines are code, which no sentence rule judges.""" + over = " ".join(["word"] * (prose_lint.SENTENCE_WORD_CAP + 1)) + self.assertEqual([], self.kinds(f'x = "{over}."\n', {"sentence-length"}, name="bait.py")) def test_the_rule_is_opt_in(self) -> None: """Adoption is incremental, so the default set stays unchanged until a promotion.""" @@ -2494,6 +2494,89 @@ def test_the_rule_is_opt_in(self) -> None: self.assertIn("sentence-length", prose_lint.RULES) +class TestCommentSentenceLength(BaitCase): + """The word cap reaches comments and docstrings, where the prose of a source file lives.""" + + def words(self, count: int) -> str: + return " ".join(["word"] * count) + + def over(self) -> str: + return self.words(prose_lint.SENTENCE_WORD_CAP + 1) + "." + + def at_cap(self) -> str: + return self.words(prose_lint.SENTENCE_WORD_CAP) + "." + + def found(self, text: str, name: str, allowed: set[int] | None = None) -> list[tuple[int, str]]: + path = self.tmp / name + path.write_text(text, encoding="utf-8") + found = prose_lint.check_file(path, {"sentence-length"}, allowed=allowed) + return [(n, kind) for n, kind, _ in found] + + def test_an_over_cap_shell_comment_is_flagged(self) -> None: + self.assertEqual( + [(2, "sentence-length")], self.found(f"set -e\n# {self.over()}\nrun\n", "bait.sh") + ) + + def test_an_at_cap_shell_comment_passes(self) -> None: + self.assertEqual([], self.found(f"set -e\n# {self.at_cap()}\nrun\n", "bait.sh")) + + def test_an_over_cap_yaml_comment_is_flagged(self) -> None: + self.assertEqual( + [(1, "sentence-length")], self.found(f"# {self.over()}\nname: value\n", "bait.yml") + ) + + def test_a_wrapped_sentence_is_counted_whole_and_reported_once(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f"x=1\n# {half}\n# {half}.\ny=2\n" + self.assertEqual([(2, "sentence-length")], self.found(text, "bait.sh")) + + def test_a_gap_ends_the_block(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f"# {half}\nx=1\n# {half}.\n" + self.assertEqual([], self.found(text, "bait.sh")) + + def test_a_code_span_in_a_comment_is_one_word(self) -> None: + span = "`" + self.words(prose_lint.SENTENCE_WORD_CAP) + "`" + self.assertEqual([], self.found(f"# The value {span} is read.\n", "bait.sh")) + + def test_the_diff_scope_drops_an_unchanged_sentence_and_keeps_a_changed_one(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f"# {self.over()}\nx=1\n# {half}\n# {half}.\n" + self.assertEqual([], self.found(text, "bait.sh", allowed={2})) + self.assertEqual([(4, "sentence-length")], self.found(text, "bait.sh", allowed={4})) + + def test_an_over_cap_docstring_sentence_is_flagged(self) -> None: + text = f'def f() -> None:\n """{self.over()}"""\n' + self.assertEqual([(2, "sentence-length")], self.found(text, "bait.py")) + + def test_a_multiline_docstring_sentence_is_counted_whole(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f'def f() -> None:\n """Summary.\n\n {half}\n {half}.\n """\n' + self.assertEqual([(4, "sentence-length")], self.found(text, "bait.py")) + + def test_a_blank_line_ends_a_docstring_paragraph(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f'def f() -> None:\n """Summary.\n\n {half}\n\n {half}.\n """\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_a_doctest_line_is_not_counted(self) -> None: + text = ( + 'def f() -> None:\n """Summary.\n\n' + f" >>> print({self.words(prose_lint.SENTENCE_WORD_CAP + 1)})\n" + ' """\n' + ) + self.assertEqual([], self.found(text, "bait.py")) + + def test_an_indented_code_block_is_not_counted(self) -> None: + text = f'def f() -> None:\n """Summary.\n\n {self.over()}\n """\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_a_python_comment_is_judged_too(self) -> None: + self.assertEqual( + [(1, "sentence-length")], self.found(f"# {self.over()}\nx = 1\n", "bait.py") + ) + + class TestSpelling(BaitCase): """US English, read from Markdown prose and from the comments of every other syntax. From 6052117df7284c79789b4347240e54d7fb3f43ec Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 12:40:08 -0700 Subject: [PATCH 2/5] Harden the Comment and Docstring Sentence Readers Share the comment filters with the wrap check, split bullets and fields, treat a colon-led indented block as code, separate adjacent docstrings, expand tabs, and mask double-backtick spans. Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/prose-gate/prose_lint.py | 93 ++++++++++++++---------- tests/test_prose_lint.py | 68 +++++++++++++++++ 2 files changed, 123 insertions(+), 38 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 5e1824bc9..20a70085d 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1801,7 +1801,7 @@ def quote(m: re.Match[str]) -> str: return '"' * (len(inner) + 1) + inner[-1] return '"' * len(m.group()) - text = re.sub(r"`[^`]*`", code, text) + text = re.sub(r"(? None: return out +def prose_comment_candidates( + path: Path, raw: str, lines: list[str] +) -> tuple[list[Comment], list[Comment], set[int]]: + """The comments a prose rule reads, the originals beside them, and the config-run lines. + + The bodies have a trailing checker directive cut off, and fenced lines are dropped. + """ + comments = python_comments(raw) if path.suffix.lower() == ".py" else None + if comments is None: + comments = extracted_comments(path, lines) + skip = fenced_lines(lines) + kept = [c for c in comments if c[0] not in skip] + trimmed = [c._replace(body=without_trailing_directive(path, c.body)) for c in kept] + return trimmed, kept, config_run_lines(trimmed) + + +def is_non_prose_comment(n: int, body: str, configured: set[int]) -> bool: + """Whether a comment body is not a sentence, so no prose rule judges it.""" + return bool( + not body + or n in configured + or NOT_PROSE.search(body) + or BARE_URI.match(body.strip()) + or KEY_ONLY.match(body) + or (SNIPPET.match(body) and not SNIPPET_END.search(body)) + ) + + def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[int, str, str]]: """Comment lines whose sentence wraps into the next, or that carry two sentences. @@ -2278,27 +2306,14 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ not end a sentence either, since a directive named mid-sentence leaves a fragment the sentence goes on past. """ - comments = python_comments(raw) if path.suffix.lower() == ".py" else None - if comments is None: - comments = extracted_comments(path, lines) - skip = fenced_lines(lines) - kept = [c for c in comments if c[0] not in skip] - comments = [c._replace(body=without_trailing_directive(path, c.body)) for c in kept] - configured = config_run_lines(comments) + comments, kept, configured = prose_comment_candidates(path, raw, lines) out: list[tuple[int, str, str]] = [] prev_body = "" prev_whole = "" prev_no = 0 for (n, body, leading, _raw), whole in zip(comments, kept, strict=True): - if ( - not body - or n in configured - or NOT_PROSE.search(body) - or BARE_URI.match(body.strip()) - or KEY_ONLY.match(body) - or (SNIPPET.match(body) and not SNIPPET_END.search(body)) - ): + if is_non_prose_comment(n, body, configured): prev_body = "" continue # An unpunctuated Markdown HTML comment is a structural marker, not commentary. @@ -2649,7 +2664,9 @@ def issue_ref_findings( DOCSTRING_OPEN = re.compile(r"^\s*[rRuU]?(?:\"\"\"|\'\'\'|\"|\')") DOCSTRING_CLOSE = re.compile(r"(?:\"\"\"|\'\'\'|\"|\')\s*$") -DOCTEST_PROMPT = re.compile(r"^\s*(?:>>>|\.\.\.)(?:\s|$)") +DOCTEST_PROMPT = re.compile(r"^\s*>>>(?:\s|$)") +DOCSTRING_CLOSED = re.compile(r"(?:\"\"\"|\'\'\'|\"|\')\s*$") +FIELD_LINE = re.compile(r"^\s*\w+(?:\s*\([^)]*\))?:(?:\s|$)") def comment_sentence_findings( @@ -2661,13 +2678,7 @@ def comment_sentence_findings( A line that is not prose, a trailing comment, or a gap ends the block. The prose filters are `comment_wrap_findings`', so the two rules agree on what a comment is. """ - comments = python_comments(raw) if path.suffix.lower() == ".py" else None - if comments is None: - comments = extracted_comments(path, lines) - skip = fenced_lines(lines) - kept = [c for c in comments if c[0] not in skip] - comments = [c._replace(body=without_trailing_directive(path, c.body)) for c in kept] - configured = config_run_lines(comments) + comments, _kept, configured = prose_comment_candidates(path, raw, lines) out: list[tuple[int, str, str]] = [] block: list[tuple[int, str]] = [] prev_no = 0 @@ -2677,21 +2688,14 @@ def flush() -> None: block.clear() for n, body, leading, _raw in comments: - if ( - not body - or n in configured - or NOT_PROSE.search(body) - or BARE_URI.match(body.strip()) - or KEY_ONLY.match(body) - or (SNIPPET.match(body) and not SNIPPET_END.search(body)) - ): + if is_non_prose_comment(n, body, configured): flush() continue - if not leading or n != prev_no + 1: + if not leading or n != prev_no + 1 or LIST_MARKER.match(body) or FIELD_LINE.match(body): flush() block.append((n, body.strip())) prev_no = n - if not leading: + if not leading or body.rstrip().endswith(":"): flush() flush() return out @@ -2710,12 +2714,16 @@ def docstring_sentence_findings( numbers = sorted(spans) groups: list[list[int]] = [] for n in numbers: - if groups and n == groups[-1][-1] + 1: + prior = "".join(spans[groups[-1][-1]]) if groups else "" + joined = bool(groups) and n == groups[-1][-1] + 1 + if joined and DOCSTRING_CLOSED.search(prior) and DOCSTRING_OPEN.match("".join(spans[n])): + joined = False + if joined: groups[-1].append(n) else: groups.append([n]) for group in groups: - texts = ["".join(spans[n]) for n in group] + texts = ["".join(spans[n]).expandtabs(4) for n in group] texts[0] = DOCSTRING_OPEN.sub("", texts[0], count=1) texts[-1] = DOCSTRING_CLOSE.sub("", texts[-1], count=1) rest = [t for t in texts[1:] if t.strip()] @@ -2725,6 +2733,7 @@ def docstring_sentence_findings( block: list[tuple[int, str]] = [] in_doctest = in_code = False prev_blank = True + prev_colon = False for idx, (n, text) in enumerate(zip(group, texts, strict=True)): indent = len(text) - len(text.lstrip()) if idx else base if not text.strip(): @@ -2732,6 +2741,7 @@ def docstring_sentence_findings( block.clear() in_doctest = in_code = False prev_blank = True + prev_colon = False continue if DOCTEST_PROMPT.match(text): out.extend(over_cap_findings(block, allowed)) @@ -2739,14 +2749,21 @@ def docstring_sentence_findings( in_doctest = True elif in_doctest or (in_code and indent >= base + 4): pass - elif prev_blank and indent >= base + 4: + elif (prev_blank or prev_colon) and indent >= base + 4: out.extend(over_cap_findings(block, allowed)) block.clear() in_code = True else: in_code = False + if LIST_MARKER.match(text) or FIELD_LINE.match(text): + out.extend(over_cap_findings(block, allowed)) + block.clear() block.append((n, text.strip())) + if text.rstrip().endswith(":"): + out.extend(over_cap_findings(block, allowed)) + block.clear() prev_blank = False + prev_colon = text.rstrip().endswith(":") out.extend(over_cap_findings(block, allowed)) return out @@ -2815,7 +2832,7 @@ def check_file( if "sentence-length" in rules: if path.suffix == ".md": out.extend(sentence_length_findings(lines, allowed)) - else: + elif path.suffix.lower() != ".md": out.extend(comment_sentence_findings(path, raw, lines, allowed)) if path.suffix.lower() == ".py": out.extend(docstring_sentence_findings(raw, lines, allowed)) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 4c54d7a0b..472bcd1e6 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2571,6 +2571,74 @@ def test_an_indented_code_block_is_not_counted(self) -> None: text = f'def f() -> None:\n """Summary.\n\n {self.over()}\n """\n' self.assertEqual([], self.found(text, "bait.py")) + def test_a_colon_line_opens_an_indented_command_block_that_is_not_counted(self) -> None: + run = " ".join(["tool list item"] * 5) + text = f'def f() -> None:\n """Usage:\n {run}\n {run}\n """\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_unpunctuated_docstring_bullets_are_separate_sentences(self) -> None: + item = self.words(9) + text = f'def f() -> None:\n """Summary.\n\n - {item}\n - {item}\n - {item}\n """\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_unpunctuated_comment_bullets_are_separate_sentences(self) -> None: + item = self.words(9) + text = f"# Steps:\n# - {item}\n# - {item}\n# - {item}\n" + self.assertEqual([], self.found(text, "bait.sh")) + + def test_adjacent_docstrings_are_judged_apart(self) -> None: + half = self.words(13) + text = f'class C:\n """{half}"""\n def f(self): """{half}"""\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_a_tab_indented_docstring_code_block_is_not_counted(self) -> None: + text = f'def f() -> None:\n\t"""Summary.\n\n\t\t{self.over()}\n\t"""\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_an_ellipsis_opening_prose_is_not_a_doctest(self) -> None: + text = f'def f() -> None:\n """Summary.\n\n ... {self.over()}\n """\n' + self.assertEqual([(4, "sentence-length")], self.found(text, "bait.py")) + + def test_doctest_output_and_continuation_lines_are_not_counted(self) -> None: + long = self.words(prose_lint.SENTENCE_WORD_CAP + 1) + text = ( + 'def f() -> None:\n """Summary.\n\n >>> x = (\n ... 1)\n' + f' {long}\n """\n' + ) + self.assertEqual([], self.found(text, "bait.py")) + + def test_prose_resumes_after_a_docstring_code_block(self) -> None: + text = ( + 'def f() -> None:\n """Summary.\n\n code line\n\n' + f' {self.over()}\n """\n' + ) + self.assertEqual([(6, "sentence-length")], self.found(text, "bait.py")) + + def test_the_diff_scope_reaches_a_docstring_continuation_line(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f'def f() -> None:\n """Summary.\n\n {half}\n {half}.\n """\n' + self.assertEqual([(5, "sentence-length")], self.found(text, "bait.py", allowed={5})) + + def test_a_trailing_comment_ends_the_block(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + self.assertEqual([], self.found(f"x=1 # {half}\n# {half}.\n", "bait.sh")) + + def test_a_non_prose_comment_ends_the_block(self) -> None: + half = self.words(prose_lint.SENTENCE_WORD_CAP // 2 + 1) + text = f"# {half}\n# https://example.com/a\n# {half}.\n" + self.assertEqual([], self.found(text, "bait.sh")) + + def test_a_single_quoted_and_a_raw_docstring_are_read(self) -> None: + single = chr(39) * 3 + for opener in ("r" + single, single, 'r"""'): + with self.subTest(opener=opener): + text = f"def f() -> None:\n {opener}{self.over()}{opener[-3:]}\n" + self.assertEqual([(2, "sentence-length")], self.found(text, "bait.py")) + + def test_a_double_backtick_span_is_one_word(self) -> None: + span = "``" + self.words(prose_lint.SENTENCE_WORD_CAP) + "``" + self.assertEqual([], self.found(f"# The value {span} is read.\n", "bait.sh")) + def test_a_python_comment_is_judged_too(self) -> None: self.assertEqual( [(1, "sentence-length")], self.found(f"# {self.over()}\nx = 1\n", "bait.py") From b3ce4c445f0a8fd3bdb402796b2f6826b46e4201 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 12:46:34 -0700 Subject: [PATCH 3/5] Split Capitalized Comment Lines and Sphinx Fields in the Sentence Reader A capitalized comment line starts a new sentence as comment-wrap reads it, a Sphinx field line starts a field, and implicit string concatenation inside a docstring no longer reads as two adjacent docstrings. Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/prose-gate/prose_lint.py | 18 +++++++++++++++--- tests/test_prose_lint.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 20a70085d..a26854adb 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -2666,7 +2666,7 @@ def issue_ref_findings( DOCSTRING_CLOSE = re.compile(r"(?:\"\"\"|\'\'\'|\"|\')\s*$") DOCTEST_PROMPT = re.compile(r"^\s*>>>(?:\s|$)") DOCSTRING_CLOSED = re.compile(r"(?:\"\"\"|\'\'\'|\"|\')\s*$") -FIELD_LINE = re.compile(r"^\s*\w+(?:\s*\([^)]*\))?:(?:\s|$)") +FIELD_LINE = re.compile(r"^\s*(?::\w+[^:]*:|\w+(?:\s*\([^)]*\))?:)(?:\s|$)") def comment_sentence_findings( @@ -2691,7 +2691,13 @@ def flush() -> None: if is_non_prose_comment(n, body, configured): flush() continue - if not leading or n != prev_no + 1 or LIST_MARKER.match(body) or FIELD_LINE.match(body): + if ( + not leading + or n != prev_no + 1 + or LIST_MARKER.match(body) + or FIELD_LINE.match(body) + or body[:1].isupper() + ): flush() block.append((n, body.strip())) prev_no = n @@ -2716,7 +2722,13 @@ def docstring_sentence_findings( for n in numbers: prior = "".join(spans[groups[-1][-1]]) if groups else "" joined = bool(groups) and n == groups[-1][-1] + 1 - if joined and DOCSTRING_CLOSED.search(prior) and DOCSTRING_OPEN.match("".join(spans[n])): + here = "".join(spans[n]) + if ( + joined + and DOCSTRING_CLOSED.search(prior) + and DOCSTRING_OPEN.match(here) + and here[:1].strip() + ): joined = False if joined: groups[-1].append(n) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 472bcd1e6..5ef7a3e60 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2639,6 +2639,25 @@ def test_a_double_backtick_span_is_one_word(self) -> None: span = "``" + self.words(prose_lint.SENTENCE_WORD_CAP) + "``" self.assertEqual([], self.found(f"# The value {span} is read.\n", "bait.sh")) + def test_capitalized_unpunctuated_comment_lines_are_separate_sentences(self) -> None: + line = self.words(9) + text = "".join(f"# Step {line}\n" for _ in range(4)) + self.assertEqual([], self.found(text, "bait.sh")) + + def test_sphinx_field_lines_are_separate_sentences(self) -> None: + field = self.words(8) + fields = "".join(f" :param p{i}: {field}\n" for i in range(4)) + text = f'def f() -> None:\n """Summary.\n\n{fields} """\n' + self.assertEqual([], self.found(text, "bait.py")) + + def test_an_implicit_concatenation_in_a_docstring_code_block_is_one_docstring(self) -> None: + code = "".join(f" call_{i}(a, b, c, d, e, f, g, h)\n" for i in range(4)) + text = ( + 'def f() -> None:\n """Example::\n\n msg = ("first "\n' + f' "second")\n{code} """\n' + ) + self.assertEqual([], self.found(text, "bait.py")) + def test_a_python_comment_is_judged_too(self) -> None: self.assertEqual( [(1, "sentence-length")], self.found(f"# {self.over()}\nx = 1\n", "bait.py") From 81864647fb25f413571b8b39370192c312867481 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 12:52:03 -0700 Subject: [PATCH 4/5] Reword a Docstring Line That Closed Its Code Span With a Possessive Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/prose-gate/prose_lint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index a26854adb..82b969943 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -2676,7 +2676,7 @@ def comment_sentence_findings( Comment lines on consecutive source lines are one block, so a wrapped sentence counts once. A line that is not prose, a trailing comment, or a gap ends the block. - The prose filters are `comment_wrap_findings`', so the two rules agree on what a comment is. + The prose filters are shared with `comment_wrap_findings`, so both rules agree on what a comment is. """ comments, _kept, configured = prose_comment_candidates(path, raw, lines) out: list[tuple[int, str, str]] = [] From 9c694791e8682c43c4e32bdef75e6f045a8143ec Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 13:01:34 -0700 Subject: [PATCH 5/5] Strip Comment Enumerators and Report Unparsed Files for Sentence Length Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/prose-gate/prose_lint.py | 6 ++++-- tests/test_prose_lint.py | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 82b969943..6a8ad4472 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -2659,7 +2659,9 @@ def issue_ref_findings( return out -PARSED_RULES = frozenset({"comment-wrap", "comment-case", "comment-added", "issue-ref"}) +PARSED_RULES = frozenset( + {"comment-wrap", "comment-case", "comment-added", "issue-ref", "sentence-length"} +) DOCSTRING_OPEN = re.compile(r"^\s*[rRuU]?(?:\"\"\"|\'\'\'|\"|\')") @@ -2699,7 +2701,7 @@ def flush() -> None: or body[:1].isupper() ): flush() - block.append((n, body.strip())) + block.append((n, ENUM_PREFIX.sub("", body).strip())) prev_no = n if not leading or body.rstrip().endswith(":"): flush() diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 5ef7a3e60..c6dbee4b9 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2658,6 +2658,10 @@ def test_an_implicit_concatenation_in_a_docstring_code_block_is_one_docstring(se ) self.assertEqual([], self.found(text, "bait.py")) + def test_a_numbered_comment_marker_is_not_a_word(self) -> None: + text = f"# 1) {self.at_cap()}\n" + self.assertEqual([], self.found(text, "bait.sh")) + def test_a_python_comment_is_judged_too(self) -> None: self.assertEqual( [(1, "sentence-length")], self.found(f"# {self.over()}\nx = 1\n", "bait.py") @@ -3116,7 +3120,7 @@ def test_a_python_file_the_interpreter_cannot_parse_is_named_by_every_parser_rul """ for name, text in (("open.py", self.UNTOKENIZED), ("newer.py", self.UNPARSED)): bait = self.bait(name, text) - for rule in ("comment-wrap", "comment-case", "issue-ref"): + for rule in ("comment-wrap", "comment-case", "issue-ref", "sentence-length"): with self.subTest(file=name, rule=rule): err = self.scope_line([bait], ["--check", rule]) self.assertIn("1 .py file(s) Python", err)