From e8ea7b19466908e1877dd2931b576460a3bc7deb Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 30 Oct 2026 12:42:15 -0700 Subject: [PATCH] Exempt a PEP 723 Script Metadata Block From the Comment Rules (#3674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #3634. - **A PEP 723 block is now an instruction to a tool rather than a comment.** The Python language data in `prose-gate.json` declares the block's form in a new `metadata_block` key: its opener, its content-line form, and its closer. `metadata_block_lines` in `prose_lint.py` returns the lines of each closed block in a comment unit. `comment-added` skips those lines. - **`comment-wrap` and `comment-case` skip the same lines.** Before, a block declaring one key reported `comment-wrap` on its opener, and the earlier test recorded that finding as one with "no remedy at all". The `comments` label does not reach that rule, and `uv` parses the block as written, so it cannot be reformatted. A block with a `[tool.uv]` table reported too. The new helper fixes that whole class of defect, so both rules now use it. - **Only a closed block on the declared form is exempt.** A prose line after the closer still reports. So does each line under an opener nothing closes, or under one whose block has a line off the form. The data is per language, so a shell file holding the same text still reports. - `scripts/README.md` states the exemption under `comment-added`. It replaces the run-based account of the PEP 723 opener in the configuration-run paragraph. ## Verification - The `prose_lint` suite passes, with 641 tests run. - In the new `comment-added` test, a block followed by a prose line reports only the prose line. The unclosed, off-form, and non-Python cases each report exactly lines 1 and 2. Against develop's script the block reports lines 1 to 3. - The `one key` and `tool table` subtests report `comment-wrap` against develop and nothing here. - ruff format, ruff check, mypy, the eol gate, the diff-scoped prose gate with `comment-added` live, and a whole-tree prose run are clean. ## Local review Three passes ran at the `frontier` tier, and the last one is recorded at `0eec2d1` with no findings. The first round's `comment-wrap` finding and its loose subtest assertions were fixed in `531d0be`. The second round's stale test docstring was fixed in `0eec2d1`. ## Pre-existing findings, filed - An extensionless script whose shebang is `#!/usr/bin/env -S uv run --script` is read with the `bash` lexer, so it gets none of the Python reading. Filed as #3673, class `pre-existing`. ## Pre-existing findings, not filed - `scripts/README.md:112`: "the exemption reaches six lines in three files" was already undercounted before this change, since every hub script's two-key PEP 723 block was itself a configuration run. Class `pre-existing`. ## Declined findings - `.github/actions/prose-gate/prose_lint.py` `metadata_block_lines`: the review asked whether to share a scanner with `front_matter_end`. That function is anchored at index 0 over raw strings, while the new helper reads `Line` units at any position, so sharing one would mean generalizing the front-matter reader. Class: consolidation. Closes on promotion: #3634 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose-gate.json | 1 + .github/actions/prose-gate/prose_lint.py | 25 ++++++++ scripts/README.md | 4 +- tests/test_prose_lint.py | 74 ++++++++++++++++------ 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/.github/actions/prose-gate/prose-gate.json b/.github/actions/prose-gate/prose-gate.json index bd2603816..31828b0a3 100644 --- a/.github/actions/prose-gate/prose-gate.json +++ b/.github/actions/prose-gate/prose-gate.json @@ -142,6 +142,7 @@ "repeat_marker": true, "docstrings": true, "trailing_directives": ["#\\s*(?:(?i:noqa|nopep8)|type:\\s*ignore)\\b", "\\bpylint:"], + "metadata_block": ["# /// script", "#(?: .*)?", "# ///"], "numpy_sections": ["attributes", "methods", "other parameters", "parameters", "raises", "receives", "returns", "warns", "yields"], "google_sections": [ "Args", "Arguments", "Attributes", "Keyword Args", "Keyword Arguments", "Methods", "Other Parameters", "Parameters", "Raise", diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 04b59598a..59e1806ad 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1470,6 +1470,30 @@ def is_comment_prose(body: str, config: Config) -> bool: ) +def without_metadata_blocks(unit: Unit, ctx: Context) -> Unit: + """The comment unit without the lines of each closed metadata block the language declares. + + A tool reads such a block rather than a person, so no comment rule reads it. A line off the + block's form, or a gap, abandons the block, so an unclosed one is left whole. + """ + form = ctx.language.get("metadata_block") + if unit.kind != "comment" or not form: + return unit + opener, body, closer = (re.compile(pattern) for pattern in form) + out: set[int] = set() + run: list[int] = [] + for line in unit.lines: + adjacent = bool(run) and line.leading and line.number == run[-1] + 1 + if adjacent and closer.fullmatch(line.raw): + out.update((*run, line.number)) + run = [] + elif adjacent and body.fullmatch(line.raw): + run.append(line.number) + else: + run = [line.number] if line.leading and opener.fullmatch(line.raw) else [] + return unit._replace(lines=tuple(line for line in unit.lines if line.number not in out)) + + def rule_comment_added(unit: Unit, ctx: Context) -> list[Finding]: """Every prose comment line opening its own line, which the diff scope narrows to a change. @@ -1793,6 +1817,7 @@ def check_file( lexer = lexer_name(path, text.split("\n", 1)[0].rstrip("\r")) config = config_for(str(root) if root else None) ctx = Context(path, root, fenced, language_of(lexer) or {}, config, allowed) + units = [without_metadata_blocks(unit, ctx) for unit in units] passes: dict[Rule, set[str]] = {} for name in RULE_TABLE: if name in rules: diff --git a/scripts/README.md b/scripts/README.md index 2cb9987a4..5bf494b1b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -61,7 +61,7 @@ Whole-tree (`python3 scripts/prose_lint.py .`) reports zero, so a finding is a l The default rule set covers comment shape (`comment-wrap` and `comment-case`) alongside the prose rules. That means a run nobody parameterized reports a wrapped comment, so the rule is enforced on every run. Comment shape is the most frequently regressed rule in agent-authored work. The tree carries no backlog of either, so both comment rules gate whole-tree, and `--diff` scopes a run rather than letting it survive a backlog. -The default set also carries `comment-added`, which reports the prose comment lines a change writes into code or config. Two weeks of fleet review rounds put 8% of every bot finding on agent-written comment text. Removing the surface was measured as the largest reduction available short of telling the reviewers not to read prose at all ([ptr727/ProjectTemplate#1631][review-cost-issue]). About half of them are classed as real defects rather than taste, so this removes a surface rather than a category of noise. The rule reports a prose comment that opens its own line, in the diff's scope, and infers nothing beyond that. A trailing comment is out of scope, because which mid-line marker opens a comment is a parser fact that differs by language. TOML and HCL carry a `#` inside a multi-line string, and a git pattern file holds no trailing comment at all. PowerShell opens one after almost anything, and shell after its metacharacters. Six attempts at deciding that from the marker's neighbourhood each got a language wrong. They did so by reporting a line holding no comment or silencing one that held several. A marker opening its line is unambiguous in all of them. `comment-wrap` and `comment-case` have judged only leading comments for years, which is why neither ever met those defects. A line holding code after a block comment's closer is out of scope too, since deleting it deletes that code. `git diff --unified=0` counts a modified line as an added one, so re-indenting a commented block and rewording a comment each report it. That is the rule's cost rather than a defect in it. The rule does not read the file at the diff's base to tell an authored comment from a touched one. That would add rename, duplicate and prose-filter cases for precision the rule does not need. A wanted comment takes the same label either way. It is the one default rule a whole-tree run does not check, since with no diff every comment in the tree is in scope. The run states that omission on stderr rather than standing down quietly. A docstring is out of scope, as is Markdown, and a file whose Python does not parse is still lexed, its docstrings staying string text. Where the comment is wanted, `--allow-comments` stands the rule down and names the act on stderr. Likewise where it is wanted, `PROSE_ALLOW_COMMENTS`, set to anything but a false spelling, does the same for a caller with nowhere to pass a flag. `false` and `0` read as off, since the composite action's own input spells its off value `false`. That is also because a value mirroring that input turned the rule off at every commit. The composite action clears that variable and passes the flag from its own `allow-comments` input. What decides a pull request is therefore whatever the calling workflow puts there. The fleet's own caller passes it for either of two reasons, the `comments` label declared in [`repo-config/labels.json`][labels], and the pull request being a release-model promotion. That is this repository's own `develop` branch into its default branch, and its diff was reviewed where it landed. On the operational model a promotion runs the rule. That is because any of its lines may have landed by direct commit and the promotion is then their first review. The caller names its model in `validate-task.yml`'s `workflow-model` input, and one passing none is read as `release`. A repository calling the action from a workflow of its own passes whatever that workflow passes. The label reaches a repository through `repo-config/configure.sh apply` rather than through this change. Where the lint log shows "Read the pull request's labels live", a label added after a run fails applies when that job reruns. Otherwise, as in a private repository, it applies to the next push, since the event's snapshot misses it ([ptr727/ProjectTemplate#2354][private-labels-issue]). +The default set also carries `comment-added`, which reports the prose comment lines a change writes into code or config. Two weeks of fleet review rounds put 8% of every bot finding on agent-written comment text. Removing the surface was measured as the largest reduction available short of telling the reviewers not to read prose at all ([ptr727/ProjectTemplate#1631][review-cost-issue]). About half of them are classed as real defects rather than taste, so this removes a surface rather than a category of noise. The rule reports a prose comment that opens its own line, in the diff's scope, and infers nothing beyond that. A trailing comment is out of scope, because which mid-line marker opens a comment is a parser fact that differs by language. TOML and HCL carry a `#` inside a multi-line string, and a git pattern file holds no trailing comment at all. PowerShell opens one after almost anything, and shell after its metacharacters. Six attempts at deciding that from the marker's neighbourhood each got a language wrong. They did so by reporting a line holding no comment or silencing one that held several. A marker opening its line is unambiguous in all of them. `comment-wrap` and `comment-case` have judged only leading comments for years, which is why neither ever met those defects. A line holding code after a block comment's closer is out of scope too, since deleting it deletes that code. `git diff --unified=0` counts a modified line as an added one, so re-indenting a commented block and rewording a comment each report it. That is the rule's cost rather than a defect in it. The rule does not read the file at the diff's base to tell an authored comment from a touched one. That would add rename, duplicate and prose-filter cases for precision the rule does not need. A wanted comment takes the same label either way. It is the one default rule a whole-tree run does not check, since with no diff every comment in the tree is in scope. The run states that omission on stderr rather than standing down quietly. A docstring is out of scope, as is Markdown, and a file whose Python does not parse is still lexed, its docstrings staying string text. So is a metadata block the language data declares, since a tool reads it rather than a person. For Python that is a script's PEP 723 block, from its `# /// script` opener through its `# ///` closer. The block leaves the comment run before any rule reads it, so `sentence-length`, `spelling`, and the other comment rules skip it too. Only a closed block on that line form is exempt. A prose line after the closer still reports, as does each line under an opener nothing closes. Where the comment is wanted, `--allow-comments` stands the rule down and names the act on stderr. Likewise where it is wanted, `PROSE_ALLOW_COMMENTS`, set to anything but a false spelling, does the same for a caller with nowhere to pass a flag. `false` and `0` read as off, since the composite action's own input spells its off value `false`. That is also because a value mirroring that input turned the rule off at every commit. The composite action clears that variable and passes the flag from its own `allow-comments` input. What decides a pull request is therefore whatever the calling workflow puts there. The fleet's own caller passes it for either of two reasons, the `comments` label declared in [`repo-config/labels.json`][labels], and the pull request being a release-model promotion. That is this repository's own `develop` branch into its default branch, and its diff was reviewed where it landed. On the operational model a promotion runs the rule. That is because any of its lines may have landed by direct commit and the promotion is then their first review. The caller names its model in `validate-task.yml`'s `workflow-model` input, and one passing none is read as `release`. A repository calling the action from a workflow of its own passes whatever that workflow passes. The label reaches a repository through `repo-config/configure.sh apply` rather than through this change. Where the lint log shows "Read the pull request's labels live", a label added after a run fails applies when that job reruns. Otherwise, as in a private repository, it applies to the next push, since the event's snapshot misses it ([ptr727/ProjectTemplate#2354][private-labels-issue]). The default set also carries `issue-ref`. It reports an issue or pull request reference in a comment, in a Python docstring, and in the instruction text an agent reads as law. That text means the Skills trees, the fleet's own rule documents, and `.github/copilot-instructions.md`. In instruction text it reports the URL spelling as well, an inline link destination, a reference definition, and a bare URL alike. It reads them off the line as written rather than off the blanked copy the bare-form scan reads. It does so because that blanking exists to hide the very destinations this half looks for. A URL is reported nowhere else, because in a code or workflow comment the rule carves out one kind of URL. That kind names an issue or a pull request on a public repository other than this one. The bare spellings, by contrast, stay banned on every surface the rule reaches. A docstring and a documentation comment lie outside that carve-out's surface as well, so a URL is banned there too. On every surface but instruction text the gate reads the bare form alone and a banned URL goes unreported. That is the same unreported half a commit reference already sits in. One forge's URL paths are read, and on its bare host and that host's `www` alias rather than any subdomain of it. This is because the tracker is served from the bare host and a subdomain is a different service on the same domain. No subdomain was measured to serve a tracker-shaped path, so the bound is the one that was measured rather than a hazard that was found. A wildcard host is the wider claim. A URL naming a tracker the pattern does not know is banned by the rule text and goes unreported. That is the relationship the rule already holds with a commit SHA. One line naming one reference reports once however many spellings of it that line carries. The key is the number both patterns capture rather than the text that spelled it. That key costs one case, stated rather than left to be found. A line naming two different projects' references under one number reports once. The second reports on the run after the first is fixed, so it is a round rather than a lost detection. A documentation comment is read like any other comment here, unlike in `comment-added`, since a reference in one is the same defect. A Markdown comment is read only where the document itself is instruction text. How far a comment read reaches is a parser fact that differs by language. `rule_issue_ref` in `.github/actions/prose-gate/prose_lint.py` is the one statement of it, since every restatement of it here was wrong in its own way. The ban the rule serves is wider than the rule, covering a commit reference as well. A commit is not a shape a gate can read. That is because a short SHA carries the same shape as a blob id, a version fragment, and a fixture hash. So the rule text in the `comment-and-doc-style` Skill covers that half and this rule reads the pattern-detectable one. A string literal is not read, because a test builds the numbers it asserts against. That is also because reading those would report a fixture rather than a claim about the repository. A tracker, a history, a plan, and a README outside those trees are the repository's own narrative and keep their references, this file among them. The tree holds no reference the rule reports. A file whose rationale blocks all ended in an issue number would teach the house style the rule forbids. No rule text competes with the surrounding file until that file changes. @@ -109,7 +109,7 @@ A comment sentence also has to start with a capital, which `comment-case` checks **A comment body of one key, a colon and one value is configuration rather than a sentence**, and neither rule applies to it. `comment-case` reads the first character, so a snippet pasted into a comment could not pass while its key opened lowercase. The keys that did pass are the ones opening on something else, each changing what a reader pastes. A capitalized, quoted or underscored key changes the key, and a leading dash is a different YAML construct from the mapping it belongs to. The same snippet already passes inside a Markdown fence, which is what makes this a gap rather than a constraint. The shape is one value carrying no space, and a body ending as a sentence is judged as one. The exemption pairs a guard with its own anchor, as `is_tool_directive` does and for the same reason. The exemption reads `SNIPPET_END` rather than `SENT_END`, whose set carries the colon. A colon ends configuration rather than a sentence here, which is why the lone-key exemption above exists. Reading it as a terminator refused `address: fd00::` and `image: ghcr.io/o/n:`. The exemption costs two detections and the guard refuses one shape, all stated rather than left to be found. The common cost is a marker comment, since `todo: refactor` and `note: obsolete` are the same shape as a config line. The second is a two-token body with no terminator inside a wrapping sentence. It loses that sentence its wrap and reports the line under it for case instead, or loses it silently where the body ends the wrap. The refusal is not a lost detection but a finding the exemption fails to remove. A value ending in a full stop is judged as before, an FQDN such as `origin: example.com.` being the case. Dropping the full stop from `SNIPPET_END` would leave `!` and `?` guarded and re-exempt the wrapped sentence the guard exists for, so it stays. `is_comment_prose` deliberately does not carry this exemption, unlike its three siblings. That is because `comment-added` reads a body of any case, and exempting the shape there would free `Owner: alice` from the label as well. Measured over this repository the exemption newly reaches one body, a command to paste. That measurement bounds neither cost nor the refusal, this tree carrying no instance of any of them. -**Two or more adjacent configuration lines are a block a reader pastes**, and neither rule applies to any of them. The line shape read here is wider than the one above. It is a key, a separator, a space, and the rest of the line, accepting `=` beside `:`. The space after the separator is required, which is what keeps a bare URI from reading as a key and a value. It is also why the unspaced `key=value` an environment file writes is not this shape. That width is what reaches a YAML tag value such as `board: !include board.yaml`, and a quoted value carrying a space such as `label: "Compressor 1"`. It also reaches a key-value line with a trailing comment. Each of those splits on a space the narrow shape's single-token value cannot contain. The run is the configuration lines themselves rather than the comment block holding them. A usage block is what decides that. Three sentences of prose above two `Run:` lines are one comment block, and exempting the block would stop both rules reading the prose. So a line joins the run only by being configuration itself. A block's own non-key-value lines need no membership of their own wherever the block holds a run at all. That is because a PEP 723 block's `# /// script` opener draws a finding only because the key-value line under it reads as a continuation of it. That is also because exempting that line clears the opener with it, while the closing `# ///` is already exempt as punctuation alone. A block declaring one key is not a run, so its opener still reports. That is what the count costs rather than a case the rule misses. The wider shape is also the shape of a marker comment, `note: obsolete` and `todo: refactor` being indistinguishable from a configuration line. Two is the count because of that, and because a marker comment is one line where a configuration block is not. The same guard the narrow shape pairs with its anchor applies here, so a body closing as a sentence does is judged as one. A trailing comment breaks a run and joins none, since it annotates the code on its own line. Three costs, stated rather than left to be found. A genuine single-line snippet in the wider shape, standing alone, still reports, which is what keeps every marker comment in the tree from going exempt. A run of two or more marker comments in the wider shape is exempt. That is the narrow shape's own documented cost reaching one line further. And the wrap cost the narrow shape's own paragraph above already states reaches further too. A sentence wrapping into a run loses its wrap silently. One wrapping out of a run has the line under it reported for case instead. The author is then told to capitalize a word that opens no sentence. The wider shape makes that class more reachable. That is because a body becomes an anchor on the wider shape where it needed the two-token one before. That holds only ever inside a run of two or more, whereas the narrow shape's own exemption reaches a body standing alone. Measured over this repository the exemption reaches six lines in three files, two usage blocks and one invocation block, and no prose line among them. None of the three costs is therefore bounded by that measurement. +**Two or more adjacent configuration lines are a block a reader pastes**, and neither rule applies to any of them. The line shape read here is wider than the one above. It is a key, a separator, a space, and the rest of the line, accepting `=` beside `:`. The space after the separator is required, which is what keeps a bare URI from reading as a key and a value. It is also why the unspaced `key=value` an environment file writes is not this shape. That width is what reaches a YAML tag value such as `board: !include board.yaml`, and a quoted value carrying a space such as `label: "Compressor 1"`. It also reaches a key-value line with a trailing comment. Each of those splits on a space the narrow shape's single-token value cannot contain. The run is the configuration lines themselves rather than the comment block holding them. A usage block is what decides that. Three sentences of prose above two `Run:` lines are one comment block, and exempting the block would stop both rules reading the prose. So a line joins the run only by being configuration itself. A Python PEP 723 block needs no run at all. The language data declares its form, so both rules skip a closed block whole, a block declaring one key included. The wider shape is also the shape of a marker comment, `note: obsolete` and `todo: refactor` being indistinguishable from a configuration line. Two is the count because of that, and because a marker comment is one line where a configuration block is not. The same guard the narrow shape pairs with its anchor applies here, so a body closing as a sentence does is judged as one. A trailing comment breaks a run and joins none, since it annotates the code on its own line. Three costs, stated rather than left to be found. A genuine single-line snippet in the wider shape, standing alone, still reports, which is what keeps every marker comment in the tree from going exempt. A run of two or more marker comments in the wider shape is exempt. That is the narrow shape's own documented cost reaching one line further. And the wrap cost the narrow shape's own paragraph above already states reaches further too. A sentence wrapping into a run loses its wrap silently. One wrapping out of a run has the line under it reported for case instead. The author is then told to capitalize a word that opens no sentence. The wider shape makes that class more reachable. That is because a body becomes an anchor on the wider shape where it needed the two-token one before. That holds only ever inside a run of two or more, whereas the narrow shape's own exemption reaches a body standing alone. Measured over this repository the exemption reaches six lines in three files, two usage blocks and one invocation block, and no prose line among them. None of the three costs is therefore bounded by that measurement. **A label opening a definition is exempt from `comment-case`, because the lowercase word is the name being defined.** A `# publish - 'true' when ...` documents an output named `publish`, and capitalizing it renames the output the workflow declares. This is the comment spelling of the `- **Label** - text` construct the dash rule already exempts. It is the same reason a step marker is stripped before the sentence checks. The exemption costs a detection, stated rather than left to be found. A comment that genuinely opens a sentence on one word followed by a spaced dash is no longer read as a lowercase opening. It is scoped to a line that opens a definition and is never applied to one continuing a wrapped sentence. This is because a continuation whose first word takes a spaced dash is a parenthetical. That is the construction the dash rule exists to catch. Both live instances in this repo are continuations, so the corpus rather than the intuition chose the unit. The first design read any comment body and would have exempted both. diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 3d8421bba..b695bb4d0 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -1621,27 +1621,22 @@ def test_a_configuration_run_is_exempt_in_the_widened_shape(self) -> None: with self.subTest(shape=label): self.assertEqual([], self.flag("a.yml", text)) - def test_a_metadata_block_opener_rides_on_the_lines_under_it(self) -> None: - """A PEP 723 block, which `uv` parses verbatim, so no reformatting is available. + def test_a_metadata_block_is_skipped_whole_whatever_it_declares(self) -> None: + """One key is not a run, and `uv` parses the block verbatim, so no reformatting is available. - The opener is not key-value and joins no run. It drew a finding only because the line - under it read as a continuation of it, so exempting that line is what clears the opener. + The language data declares the block's form, so the rules skip it whole rather than by run. """ - block = ( - '# /// script\n# requires-python = ">=3.9"\n# dependencies = ["bleak"]\n# ///\nx = 1\n' + table = ( + '# /// script\n# dependencies = [\n# "bleak",\n# ]\n#\n# [tool.uv]\n' + '# exclude-newer = "2026-01-01T00:00:00Z"\n# ///\nx = 1\n' ) - self.assertEqual([], self.flag("a.py", block)) - - def test_a_metadata_block_declaring_one_key_is_not_a_run(self) -> None: - """The count's own cost, reached by a block rather than by a marker comment. - - One key-value line is not a run, so nothing carries the opener and it reports. `uv` parses - the block verbatim, so no reformatting is available either, and the `comments` label reaches - `comment-added` rather than this rule. The finding therefore stands with no remedy at all, - which is what the count costs on a block rather than on a marker comment. - """ - block = '# /// script\n# requires-python = ">=3.9"\n# ///\nx = 1\n' - self.assertEqual(["comment-wrap"], self.flag("a.py", block)) + for label, block in ( + ("one key", '# /// script\n# requires-python = ">=3.9"\n# ///\nx = 1\n'), + ("two keys", '# /// script\n# requires-python = ">=3.9"\n# dependencies = []\n# ///\n'), + ("tool table", table), + ): + with self.subTest(case=label): + self.assertEqual([], self.flag("a.py", block)) def test_a_sentence_wrapping_into_a_run_loses_its_wrap(self) -> None: """The third cost, pinned so a later change meets it rather than finds it. @@ -2167,6 +2162,49 @@ def test_a_directive_name_opening_a_sentence_is_still_prose(self) -> None: with self.subTest(line=line.strip()): self.assertEqual(expected, self.kinds(line, {"comment-added"}, name="tool.py")) + def test_a_python_metadata_block_is_an_instruction_to_a_tool(self) -> None: + """A PEP 723 block, opener through closer, which `uv` reads rather than a person. + + The exemption is the closed block alone. A prose line after the closer still reports, and + so does each line under an opener nothing closes, or one the block's line form breaks. + """ + block = '# /// script\n# requires-python = ">=3.13"\n# dependencies = ["bleak"]\n# ///\n' + after = "# The value is read once here.\nvalue = 1\n" + self.assertEqual([], self.kinds(block + "value = 1\n", {"comment-added"}, name="tool.py")) + self.assertEqual( + [(5, "comment-added")], + [ + (n, kind) + for n, kind, _ in prose_lint.check_file( + self._write(block + after, "tool.py"), {"comment-added"} + ) + ], + ) + for label, text, name in ( + ("unclosed", '# /// script\n# requires-python = ">=3.13"\nvalue = 1\n', "tool.py"), + ("off form", '# /// script\n#requires-python = ">=3.13"\n# ///\n', "tool.py"), + ("not python", '# /// script\n# requires-python = ">=3.13"\n# ///\n', "tool.sh"), + ("not script", '# /// notes\n# requires-python = ">=3.13"\n# ///\n', "tool.py"), + ): + with self.subTest(case=label): + found = prose_lint.check_file(self._write(text, name), {"comment-added"}) + self.assertEqual([1, 2], [n for n, _, _ in found]) + + def test_no_comment_rule_reads_a_python_metadata_block(self) -> None: + """The block leaves the comment unit before any rule runs, so no rule needs its own skip. + + A long dependency list read as one sentence, and a package spelled the British way, each + reported. The same text in a shell file still does, which is what makes the empty list mean + something. + """ + deps = "".join(f'# "package-{n}",\n' for n in range(30)) + text = f'# /// script\n# dependencies = [\n# "colour",\n{deps}# ]\n# ///\nvalue = 1\n' + rules = {name for name, (_, kinds) in prose_lint.RULE_TABLE.items() if "comment" in kinds} + self.assertEqual([], self.kinds(text, rules, name="tool.py")) + self.assertLessEqual( + {"sentence-length", "spelling"}, set(self.kinds(text, rules, name="tool.sh")) + ) + def test_a_reference_and_a_key_are_not_prose(self) -> None: for line in ("# https://example.invalid/spec\n", "# ignore:\n"): with self.subTest(line=line.strip()):