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..6a8ad4472 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. @@ -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"(? 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): @@ -2256,6 +2268,34 @@ def flush() -> 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. @@ -2266,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. @@ -2632,7 +2659,127 @@ 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]?(?:\"\"\"|\'\'\'|\"|\')") +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+[^:]*:|\w+(?:\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 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]] = [] + 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 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) + or body[:1].isupper() + ): + flush() + block.append((n, ENUM_PREFIX.sub("", body).strip())) + prev_no = n + if not leading or body.rstrip().endswith(":"): + 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: + prior = "".join(spans[groups[-1][-1]]) if groups else "" + joined = bool(groups) and n == groups[-1][-1] + 1 + 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) + else: + groups.append([n]) + for group in groups: + 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()] + 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 + 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(): + out.extend(over_cap_findings(block, allowed)) + 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)) + block.clear() + in_doctest = True + elif in_doctest or (in_code and indent >= base + 4): + pass + 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 def check_file( @@ -2696,8 +2843,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)) + 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)) 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..c6dbee4b9 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,180 @@ 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_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_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_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") + ) + + class TestSpelling(BaitCase): """US English, read from Markdown prose and from the comments of every other syntax. @@ -2946,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)