diff --git a/.editorconfig b/.editorconfig index f994e2e2..1cb9cf82 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ # https://github.com/dotnet/runtime/blob/main/.editorconfig # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format -# dotnet format style --verify-no-changes --severity=info --verbosity=detailed +# Verify with: dotnet format style --verify-no-changes --severity=info --verbosity=detailed # Root config root = true diff --git a/scripts/README.md b/scripts/README.md index 09c3725e..1a68bf97 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -58,6 +58,8 @@ A string that spans lines carries its state onto the lines it covers, so a marke A comment sentence also has to start with a capital, which `comment-case` checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as `comment-wrap`, and a lowercase opening that is not a continuation reports as `comment-case`. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule. +**A comment whose whole body is a URI is a reference rather than a sentence**, and neither rule applies to it. It cannot be capitalized or restructured without corrupting the address it exists to carry, so before the exemption every repo carrying a reference block inherited a finding no edit could answer. Consecutive reference lines are separate addresses rather than one sentence wrapping, which is why the exemption also stops the line below a URI from reading as its continuation. A URI inside a sentence is still prose, so the exemption requires the whole body to be the address and nothing else. + `charset` and `dupword` are clean tree-wide and gate CI. `charset-unknown`, `semicolon`, `dash`, `comment-wrap`, and `comment-case` run as one warn-only CI step, so the backlog is visible without blocking and is corrected as each file is next edited. ## `repo_gate.py` diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index a3b5d5fb..4c11d86b 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -517,6 +517,14 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: r'|cSpell|markdownlint|omit from toc|prettier|eslint|SPDX|Copyright' r'|^v\d+(\.\d+)*$') +# A comment that is only a URI is a reference, not a sentence, so neither case nor wrap applies. +# It cannot be capitalized or restructured without corrupting the address it exists to carry. +# A URI inside a sentence is still prose, so the whole body has to be the address and nothing else. +# The angle brackets are matched as a pair or not at all. +# One bracket alone is a typo, and exempting it would hide the typo rather than report it. +# The scheme is case-insensitive per RFC 3986, so an uppercase one is the same reference. +BARE_URI = re.compile(r'^(?:<(?:https?|ftp)://[^>\s]+>|(?:https?|ftp)://[^>\s]+)$', re.IGNORECASE) + # Two sentences on one line, guarded against an abbreviation, an initial, or a dotted identifier. # The initial guard anchors on a word boundary, so `J. Smith` reads as one name. # A sentence ending in an acronym such as CI is two sentences and has to be caught. @@ -755,7 +763,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ prev_body = '' prev_no = 0 for n, body, leading in comments: - if not body or NOT_PROSE.search(body): + if not body or NOT_PROSE.search(body) or BARE_URI.match(body.strip()): prev_body = '' continue if RUN_ON.search(strip_inline_code(body)): diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index a8e77474..a3e567cd 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -374,6 +374,50 @@ def test_a_marker_inside_a_string_is_not_a_comment(self) -> None: with self.subTest(file=name): self.assertEqual([], self.flag(name, text)) + def test_a_comment_that_is_only_a_uri_is_a_reference_not_a_sentence(self) -> None: + """It cannot be capitalized or restructured without corrupting the address it carries. + + A reference block opening a config file is the ordinary shape, so before this exemption + every repo carrying one inherited a finding no edit could answer. + """ + for name, text in (('a.yml', '# https://docs.github.com/en/code-security/dependabot\n'), + ('.editorconfig', '; https://editorconfig.org\n'), + ('a.cs', '// http://example.com/a_b.c\n'), + ('a.xml', '\n'), + ('a.yml', '# \n'), + ('a.sh', '# ftp://example.com/pub\n')): + with self.subTest(file=name, comment=text.strip()): + self.assertEqual([], self.flag(name, text)) + + def test_a_uri_block_does_not_make_the_next_line_a_continuation(self) -> None: + """Consecutive reference lines are separate addresses, not one sentence wrapping.""" + self.assertEqual([], self.flag('a.yml', '# https://example.com/one\n' + '# https://example.com/two\n')) + + def test_the_scheme_is_case_insensitive(self) -> None: + """RFC 3986 makes the scheme case-insensitive, so an uppercase one is the same reference. + + The continuation case is the one that matters: a scheme the exemption misses puts the + reference line back in the wrap logic, which is the false positive this exemption removes. + """ + self.assertEqual([], self.flag('a.yml', '# HTTPS://example.com/one\n' + '# Describes the format.\n')) + self.assertEqual([], self.flag('a.yml', '# Https://example.com/one\n')) + + def test_an_unbalanced_angle_bracket_is_not_a_delimited_uri(self) -> None: + """One bracket is a typo rather than a delimiter, so it is reported instead of exempted.""" + for body in (''): + with self.subTest(body=body): + self.assertFalse(prose_lint.BARE_URI.match(body)) + self.assertTrue(prose_lint.BARE_URI.match('')) + + def test_a_uri_inside_a_sentence_is_still_prose(self) -> None: + """The whole body has to be the address, or the exemption would swallow real prose.""" + self.assertEqual(['comment-wrap'], + self.flag('a.yml', f'# See https://example.com. {self.RUN_ON}\n')) + self.assertEqual(['comment-case'], + self.flag('a.yml', '# see https://example.com for the options\n')) + def test_a_documentation_comment_is_left_to_codestyle(self) -> None: """An XML doc comment and a docstring may run to paragraphs, which CODESTYLE governs.""" self.assertEqual([], self.flag('a.cs', f'/// {self.RUN_ON}\n'))