From 37d87323ac3e36cd0454747fcc28661f57c58427 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 20 Sep 2026 08:34:12 -0700 Subject: [PATCH 1/2] Scope the Reference Ban on Whose Tracker, and Read a Configuration Block Three findings against the prose gate and the rule it serves, landed together because the second of them without the third is the uniform error the third exists to prevent. The configuration-snippet exemption bounded a value at one whitespace-free token, so a YAML tag value, a quoted value carrying a space, and a key-value line with a trailing comment each read as prose and drew a finding telling the author to capitalize a key a reader pastes. A wider shape now reads a key, a separator, a space, and the rest of the line, accepting `=` beside `:`, and it applies to two or more adjacent lines rather than to one, since the wider shape is also the shape of a marker comment and a marker comment is one line. The run is the configuration lines themselves rather than the comment block holding them, because a usage block opens on prose and exempting the block would stop both comment rules reading it. Three costs come with it and all three are written down, the count's own cost on a block declaring one key among them. The `issue-ref` rule read the bare reference shape only, and it blanks a URI and a link destination before the scan, so every URL spelling of the same reference passed. In instruction text it now reads an inline destination, a reference definition, and a bare URL for one forge's issue and pull paths, on the bare host and that host's `www` alias rather than any subdomain of it. One line naming one reference reports once however many spellings of it that line carries, the key being the number both patterns capture rather than the text that spelled it. A gate reporting the bare spelling while passing the URL points the author at respelling the reference rather than at removing it, which is what it did: a downstream sweep took three bare references green as reference-style links naming the same pull request. The rule text was scoped on which file, and its own three reasons are not scoped that way. It now states the ban whole and unqualified, as it did before, and carries the rescope as one carve-out beside it: in a code or workflow comment, a URL naming an issue or a pull request on a public repository other than this one is a source citation and is permitted. The revision record this section already carried is named as the second carve-out rather than left as an unclaimed exception, since a disproved-claims entry names the revision its proof was read against and an unqualified claim that the section had one carve-out would have a reader delete it. Whatever neither carve-out affirmatively permits is banned by the sentence above them, and no list of banned cases follows, deliberately. Stating the cases that are permitted rather than the cases that are banned is what closes this: nine local review rounds over enumerated forms left a different case uncovered or contradicted each time, a public same-owner sibling's tracker and the section's own revision record being the last two. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/comment-and-doc-style/SKILL.md | 68 +++++-- .../.source-digests/comment-and-doc-style | 2 +- .../skills/comment-and-doc-style/SKILL.md | 68 +++++-- .github/actions/prose-gate/prose_lint.py | 122 +++++++++++- .github/skills/comment-and-doc-style/SKILL.md | 68 +++++-- GOVERNANCE.md | 2 +- scripts/README.md | 4 +- scripts/tests/test_prose_lint.py | 187 +++++++++++++++++- 8 files changed, 459 insertions(+), 62 deletions(-) diff --git a/.agents/skills/comment-and-doc-style/SKILL.md b/.agents/skills/comment-and-doc-style/SKILL.md index c133f1377..8c4351f74 100644 --- a/.agents/skills/comment-and-doc-style/SKILL.md +++ b/.agents/skills/comment-and-doc-style/SKILL.md @@ -203,18 +203,25 @@ a hook runs on every commit while the label decides a pull request. ## Issue, pull request, and commit references No comment, no docstring, and no instruction document names an issue, a pull request, or a commit. -The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills -trees, and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, +The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills trees, +and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `RESYNC.md`, `STANDUP.md`, `WORKFLOW.md`, and `.github/copilot-instructions.md`. A tracker, a history, a plan, and a README outside those trees are the repository's own narrative and keep their references, as do a commit message and a pull request body, which are the surfaces a reference belongs on. -A record whose subject is the revision itself keeps it. A disproved-claims entry in -`.github/copilot-instructions.md` names the revision its proof was read against, since a proof is -true of one tree at one revision and an entry whose subject has moved is deleted rather than edited -to look current. The revision there is the record's own load-bearing field rather than a citation -beside a claim, which is the distinction this rule turns on. +Two carve-outs, each stated as a single case. Whatever neither of them affirmatively permits is +banned by the paragraph above, which is the whole of the test and is why no list of banned cases +follows. The first: **in a code or workflow comment, a URL naming an issue or a pull request on a +public repository other than this one is a source citation and is permitted.** It does the same job +as the datasheet link, the vendor wiki link, and the SDK doc link the rule already leaves alone on +the adjacent line. The second is the revision record in the paragraph below. + +The second carve-out: a record whose subject is the revision itself keeps it. A disproved-claims +entry in `.github/copilot-instructions.md` names the revision its proof was read against, since a +proof is true of one tree at one revision and an entry whose subject has moved is deleted rather +than edited to look current. The revision there is the record's own load-bearing field rather than a +citation beside a claim, which is the distinction this rule turns on. Three reasons, and the first decides it. @@ -226,18 +233,45 @@ Three reasons, and the first decides it. - **It pollutes the content.** A rationale block that takes one more citation per round is how a file comes to teach a house style the rules forbid, which is what happened here. +The first carve-out is where all three fail at once, which is what makes it one case rather than a +taxonomy. Another repository's status is not a fact this file can hold, it changes without anyone +touching this file, and the constraint the citation stands in for is therefore unwritable. The +lookup is not impossible, that repository being public. And a URL line is not pollution on a surface +where the datasheet, the forum thread, and the component docs already sit on the adjacent lines, the +hostname being the only thing that separates them. A workaround whose justification is an open +report on the project it works around is the routine case, and a reader revisiting the workaround +needs to know whether the cause still stands. + +Move one of that carve-out's conditions and a reason comes back. An instruction document states its +constraint rather than citing a tracker for it, so the first reason holds there whatever the tracker +names. A private repository's tracker cannot be opened by a reader of the content carried into a +public repository, which is the second reason exactly. This repository's own tracker holds status +this file can state, so the first and the third hold on every surface. And a bare reference carries +no destination a reader can open at all, the hash-and-number form resolving against whichever +repository the reader happens to be in, which is why that carve-out is written as a URL. + Write the constraint the reference was standing in for, or drop the clause where the reference was the whole of its value. "A prior version re-scanned from every unmatched open, which was O(N^2)" -carries what the reader needs, and the number of the round that found it does not. - -The `issue-ref` rule in the prose gate reads the pattern-detectable half of this, an issue or pull -request reference in a comment, in a Python docstring, and in instruction text. Whatever a gate -does not reach is unlicensed all the same, since the rule binds a reader rather than a scan. - -A commit is not a shape a gate can read, since a short SHA carries the same shape as a blob id, a -version fragment, and a fixture hash, so the text above is the whole of what covers that half. A -reference in a string literal is not read either: a test builds the numbers it asserts against, and -reading those would report a fixture rather than a claim about this repository. +carries what the reader needs, and the number of the round that found it does not. Inside the +carve-out there is nothing to rewrite, the referenced thing being live status somewhere else, and +the carve-out is why that case needs no remedy rather than a remedy an author is expected to find. + +The `issue-ref` rule in the prose gate reads the pattern-detectable half of this: a bare reference +in a comment, in a Python docstring, and in instruction text, and in instruction text a URL naming +an issue or a pull request as well, written as an inline link destination, as a reference +definition, or bare in the prose. It reads one forge's URL paths, so a URL naming a tracker it does +not know is banned there and goes unreported. Whatever a gate does not reach is unlicensed all the +same, since the rule binds a reader rather than a scan. Reading the URL in instruction text is what +stops the gate reporting the bare spelling and passing the URL on the one surface it reads both, +since an author met by the bare form's finding is otherwise pointed at respelling the reference +rather than at removing it. On every other surface the gate reads the bare form alone, so a banned +URL is banned there and goes unreported. + +A bare commit reference is not a shape a gate can read, since a short SHA carries the same shape as +a blob id, a version fragment, and a fixture hash. The URL spelling of one is readable and is read +by nothing here, so the text above is the whole of what covers a commit either way. A reference in a +string literal is not read either: a test builds the numbers it asserts against, and reading those +would report a fixture rather than a claim about this repository. ## Character set 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 b16386ad0..a0de1b3a7 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 @@ -355f4bc2f77d5a93 +d4024b32e9692f89 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 c133f1377..8c4351f74 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 @@ -203,18 +203,25 @@ a hook runs on every commit while the label decides a pull request. ## Issue, pull request, and commit references No comment, no docstring, and no instruction document names an issue, a pull request, or a commit. -The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills -trees, and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, +The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills trees, +and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `RESYNC.md`, `STANDUP.md`, `WORKFLOW.md`, and `.github/copilot-instructions.md`. A tracker, a history, a plan, and a README outside those trees are the repository's own narrative and keep their references, as do a commit message and a pull request body, which are the surfaces a reference belongs on. -A record whose subject is the revision itself keeps it. A disproved-claims entry in -`.github/copilot-instructions.md` names the revision its proof was read against, since a proof is -true of one tree at one revision and an entry whose subject has moved is deleted rather than edited -to look current. The revision there is the record's own load-bearing field rather than a citation -beside a claim, which is the distinction this rule turns on. +Two carve-outs, each stated as a single case. Whatever neither of them affirmatively permits is +banned by the paragraph above, which is the whole of the test and is why no list of banned cases +follows. The first: **in a code or workflow comment, a URL naming an issue or a pull request on a +public repository other than this one is a source citation and is permitted.** It does the same job +as the datasheet link, the vendor wiki link, and the SDK doc link the rule already leaves alone on +the adjacent line. The second is the revision record in the paragraph below. + +The second carve-out: a record whose subject is the revision itself keeps it. A disproved-claims +entry in `.github/copilot-instructions.md` names the revision its proof was read against, since a +proof is true of one tree at one revision and an entry whose subject has moved is deleted rather +than edited to look current. The revision there is the record's own load-bearing field rather than a +citation beside a claim, which is the distinction this rule turns on. Three reasons, and the first decides it. @@ -226,18 +233,45 @@ Three reasons, and the first decides it. - **It pollutes the content.** A rationale block that takes one more citation per round is how a file comes to teach a house style the rules forbid, which is what happened here. +The first carve-out is where all three fail at once, which is what makes it one case rather than a +taxonomy. Another repository's status is not a fact this file can hold, it changes without anyone +touching this file, and the constraint the citation stands in for is therefore unwritable. The +lookup is not impossible, that repository being public. And a URL line is not pollution on a surface +where the datasheet, the forum thread, and the component docs already sit on the adjacent lines, the +hostname being the only thing that separates them. A workaround whose justification is an open +report on the project it works around is the routine case, and a reader revisiting the workaround +needs to know whether the cause still stands. + +Move one of that carve-out's conditions and a reason comes back. An instruction document states its +constraint rather than citing a tracker for it, so the first reason holds there whatever the tracker +names. A private repository's tracker cannot be opened by a reader of the content carried into a +public repository, which is the second reason exactly. This repository's own tracker holds status +this file can state, so the first and the third hold on every surface. And a bare reference carries +no destination a reader can open at all, the hash-and-number form resolving against whichever +repository the reader happens to be in, which is why that carve-out is written as a URL. + Write the constraint the reference was standing in for, or drop the clause where the reference was the whole of its value. "A prior version re-scanned from every unmatched open, which was O(N^2)" -carries what the reader needs, and the number of the round that found it does not. - -The `issue-ref` rule in the prose gate reads the pattern-detectable half of this, an issue or pull -request reference in a comment, in a Python docstring, and in instruction text. Whatever a gate -does not reach is unlicensed all the same, since the rule binds a reader rather than a scan. - -A commit is not a shape a gate can read, since a short SHA carries the same shape as a blob id, a -version fragment, and a fixture hash, so the text above is the whole of what covers that half. A -reference in a string literal is not read either: a test builds the numbers it asserts against, and -reading those would report a fixture rather than a claim about this repository. +carries what the reader needs, and the number of the round that found it does not. Inside the +carve-out there is nothing to rewrite, the referenced thing being live status somewhere else, and +the carve-out is why that case needs no remedy rather than a remedy an author is expected to find. + +The `issue-ref` rule in the prose gate reads the pattern-detectable half of this: a bare reference +in a comment, in a Python docstring, and in instruction text, and in instruction text a URL naming +an issue or a pull request as well, written as an inline link destination, as a reference +definition, or bare in the prose. It reads one forge's URL paths, so a URL naming a tracker it does +not know is banned there and goes unreported. Whatever a gate does not reach is unlicensed all the +same, since the rule binds a reader rather than a scan. Reading the URL in instruction text is what +stops the gate reporting the bare spelling and passing the URL on the one surface it reads both, +since an author met by the bare form's finding is otherwise pointed at respelling the reference +rather than at removing it. On every other surface the gate reads the bare form alone, so a banned +URL is banned there and goes unreported. + +A bare commit reference is not a shape a gate can read, since a short SHA carries the same shape as +a blob id, a version fragment, and a fixture hash. The URL spelling of one is readable and is read +by nothing here, so the text above is the whole of what covers a commit either way. A reference in a +string literal is not read either: a test builds the numbers it asserts against, and reading those +would report a fixture rather than a claim about this repository. ## Character set diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index f04f84b52..e23482837 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -767,11 +767,33 @@ def is_operations_runbook(path: Path, root: Path | None) -> bool: # So is a hash-delimited expression a leading comment quotes and whose text opens on digits. # A `sed` script and a pattern file's own commented-out entry are the two shapes of that. # A repository whose numbering reaches either bound states a reference the rule text still bans. -ISSUE_REF = re.compile(r"(?\])]+", re.IGNORECASE) +# The link spelling of the same reference, read in instruction text and nowhere else. +# `without_lookalikes` blanks a URI and a link destination before `ISSUE_REF` reads the line. +# That is why an anchor and a fragment opening on digits go unreported. +# It is also why every link form of a real reference passed. +# A destination names what it points at, so it is as detectable as the bare form. +# An author met by the bare form's finding reaches for the link form next. +# The host enumeration is one forge rather than every forge. +# Each forge spells its own paths differently, and encoding several grammars here grows defects. +# The rule text bans the reference whatever spells it, so an unmatched forge is unlicensed. +# That is the relationship the rule already holds with a commit SHA. +# The host is the bare one and its `www` alias, never an arbitrary subdomain of it. +# The tracker is served from the bare host, and a subdomain is a different service on that domain. +# No subdomain was measured to serve a tracker-shaped path, so this is the bound that was measured. +# A wildcard host is the wider claim, and the wider claim is the one that has to be earned. +# The digits are captured so a line naming one reference twice over reports it once. +# That key collapses two different references sharing a number on one line as well. +# The second is reported once the first is fixed, so it is a round rather than a lost detection. +FORGE_REF = re.compile( + r"https?://(?:www\.)?github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/([0-9]+)", + re.IGNORECASE, +) + # The Skills roots, source and both generated distributions, whose every Markdown file is rule text. # A plugin tree carries one root per plugin, so that one is matched by its two fixed ends. # Matching any directory named `skills` instead would gate a product's own source tree as fleet law. @@ -1550,6 +1572,25 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: SNIPPET = re.compile(r"^\S+:\s+\S+$") SNIPPET_END = re.compile(r"[.!?][\"')\]]?\s*$") +# A key, a separator, a space, and the rest of the line, the shape `SNIPPET` could not reach. +# `SNIPPET` bounds the value at one whitespace-free token, so three real shapes split on a space. +# A YAML tag value splits on the space between the tag and its argument. +# A quoted value carrying a space splits on that space. +# A key-value line with a trailing comment splits on the space before the marker. +# None of the three is prose under any reading, and each is a line a reader pastes. +# `=` is accepted beside `:` because the spaced form spells the same shape, TOML writing one. +# The space after the separator is required, which is what keeps a bare URI from being a key. +# So the unspaced `key=value` an environment file writes is not this shape and is not exempt. +CONFIG_LINE = re.compile(r"^\S+\s*[:=]\s+\S") + +# How many configuration lines a contiguous comment run needs before the run is exempt. +# `CONFIG_LINE` alone reads a marker comment as configuration. +# `note: obsolete` and `todo: refactor` carry exactly the shape of one. +# So an anchor read line by line would exempt every marker comment in the tree. +# A marker comment is one line and a configuration block is not. +# The count is what separates them, and two is the smallest count that does. +CONFIG_RUN_MIN = 2 + # A label opening a definition names the thing being defined, so it is not the sentence's first word. # `# publish - 'true' when ...` documents an output named `publish`. # Capitalizing it renames the output the workflow declares. @@ -1867,6 +1908,53 @@ def fenced_lines(lines: list[str]) -> set[int]: return out +def is_config_line(body: str) -> bool: + """Whether a comment body is one line of configuration rather than a sentence. + + The guard is the one `SNIPPET` already pairs with its own anchor: a body closing as a sentence + does is judged as one, so `Note: the value is read once.` is prose whose first word happens to + take a colon. What follows a real key is a value, and a value does not end in a full stop. + """ + return bool(CONFIG_LINE.match(body)) and not SNIPPET_END.search(body) + + +def config_run_lines(comments: list[Comment]) -> set[int]: + """The lines of every run of `CONFIG_RUN_MIN` or more adjacent configuration comment lines. + + The run is the unit rather than the line, and the run is the configuration lines themselves + rather than the comment block holding them. A usage block is the case that decides it: three + sentences of prose above two `Run:` lines is one comment block, and exempting the block would + stop both comment rules reading the prose. So a line joins 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. A PEP 723 block opens on `# /// script`, whose body draws a finding only because the + key-value line under it reads as a continuation, and exempting that line is what clears the + opener with it. Its closing `# ///` is already exempt as `NOT_PROSE`, being punctuation alone. + A block declaring one key is not a run, so its opener still reports, and the count is what that + costs rather than a case this misses. + + A trailing comment breaks a run and joins none, since it annotates the code on its own line + rather than continuing the comment above it. + """ + out: set[int] = set() + run: list[int] = [] + + def flush() -> None: + if len(run) >= CONFIG_RUN_MIN: + out.update(run) + run.clear() + + for comment in comments: + if not (comment.leading and is_config_line(comment.body)): + flush() + continue + if run and comment.line != run[-1] + 1: + flush() + run.append(comment.line) + flush() + return out + + 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. @@ -1878,6 +1966,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ comments = extracted_comments(path, lines) skip = fenced_lines(lines) comments = [c for c in comments if c[0] not in skip] + configured = config_run_lines(comments) out: list[tuple[int, str, str]] = [] prev_body = "" @@ -1885,6 +1974,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ 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) @@ -2123,6 +2213,16 @@ def issue_ref_findings( carry exactly these references. Every line of such a document is read, so a trailing HTML comment in one is read with the prose around it rather than through the comment parser. + Instruction text is also read for the link spelling, an inline destination, a reference + definition, and a bare URL alike, each being a substring of the line the scan already holds. + That scan reads the line as written rather than the blanked copy `ISSUE_REF` reads, since the + blanking exists to hide exactly the destinations this half is looking for. Nowhere else is read + for it, and the rule is what decides that rather than the scan. In a code or workflow comment it + carves out a link naming an issue or a pull request on a public repository other than this one, + the source citation for the line under it. A docstring and a documentation comment fall under no + carve-out, so a link there is banned and goes unread, as one in a code comment does whenever the + carve-out's own terms are not met. + Elsewhere a comment is read where `comment_bodies` reports its marker as opening its line, which a continuation line of an open block comment is reported as doing, having no marker of its own to judge. A `.py` that does not tokenize yields no comment at all there, so a reference @@ -2136,6 +2236,7 @@ def issue_ref_findings( """ out: list[tuple[int, str, str]] = [] spans: dict[int, list[str]] = {} + links: dict[int, list[str]] = {} if path.suffix.lower() == ".md": if not is_instruction_text(path, root): return out @@ -2143,6 +2244,7 @@ def issue_ref_findings( for n, line in enumerate(lines, 1): if n not in fenced: spans.setdefault(n, []).append(without_lookalikes(line, True)) + links.setdefault(n, []).append(line) else: for comment in comment_bodies(path, raw, comment_syntax_including_docs(path)) or (): # A comment whose marker opens its line, and no other. @@ -2164,13 +2266,21 @@ def issue_ref_findings( # One finding per line per distinct reference, the line being the unit this rule reports on. # `comment-added` counts a line once for the same reason, a line with two comments being one line. # Here one comment is read twice over as well, as written and with its marker off. + # The key is the number both patterns capture rather than the text that spelled it. + # A link carries its reference in the destination and the bare form of it in the link text. + # Keyed on the text, the two spellings of one reference never collide and the line reports twice. + # The bare scan runs first, so a line carrying both is reported as the spelling to fix first. + # The cost is a line naming two different projects' references under one number, reported once. + # The second reports on the run after the first is fixed, which the author is already making. seen: set[tuple[int, str]] = set() - for n in sorted(spans): - for text in spans[n]: - for m in ISSUE_REF.finditer(text): - if (n, m.group(0)) in seen: + for n in sorted(set(spans) | set(links)): + found = [(ISSUE_REF, t) for t in spans.get(n, ())] + found += [(FORGE_REF, t) for t in links.get(n, ())] + for pattern, text in found: + for m in pattern.finditer(text): + if (n, m.group(1)) in seen: continue - seen.add((n, m.group(0))) + seen.add((n, m.group(1))) out.append( ( n, diff --git a/.github/skills/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md index c133f1377..8c4351f74 100644 --- a/.github/skills/comment-and-doc-style/SKILL.md +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -203,18 +203,25 @@ a hook runs on every commit while the label decides a pull request. ## Issue, pull request, and commit references No comment, no docstring, and no instruction document names an issue, a pull request, or a commit. -The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills -trees, and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, +The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills trees, +and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `RESYNC.md`, `STANDUP.md`, `WORKFLOW.md`, and `.github/copilot-instructions.md`. A tracker, a history, a plan, and a README outside those trees are the repository's own narrative and keep their references, as do a commit message and a pull request body, which are the surfaces a reference belongs on. -A record whose subject is the revision itself keeps it. A disproved-claims entry in -`.github/copilot-instructions.md` names the revision its proof was read against, since a proof is -true of one tree at one revision and an entry whose subject has moved is deleted rather than edited -to look current. The revision there is the record's own load-bearing field rather than a citation -beside a claim, which is the distinction this rule turns on. +Two carve-outs, each stated as a single case. Whatever neither of them affirmatively permits is +banned by the paragraph above, which is the whole of the test and is why no list of banned cases +follows. The first: **in a code or workflow comment, a URL naming an issue or a pull request on a +public repository other than this one is a source citation and is permitted.** It does the same job +as the datasheet link, the vendor wiki link, and the SDK doc link the rule already leaves alone on +the adjacent line. The second is the revision record in the paragraph below. + +The second carve-out: a record whose subject is the revision itself keeps it. A disproved-claims +entry in `.github/copilot-instructions.md` names the revision its proof was read against, since a +proof is true of one tree at one revision and an entry whose subject has moved is deleted rather +than edited to look current. The revision there is the record's own load-bearing field rather than a +citation beside a claim, which is the distinction this rule turns on. Three reasons, and the first decides it. @@ -226,18 +233,45 @@ Three reasons, and the first decides it. - **It pollutes the content.** A rationale block that takes one more citation per round is how a file comes to teach a house style the rules forbid, which is what happened here. +The first carve-out is where all three fail at once, which is what makes it one case rather than a +taxonomy. Another repository's status is not a fact this file can hold, it changes without anyone +touching this file, and the constraint the citation stands in for is therefore unwritable. The +lookup is not impossible, that repository being public. And a URL line is not pollution on a surface +where the datasheet, the forum thread, and the component docs already sit on the adjacent lines, the +hostname being the only thing that separates them. A workaround whose justification is an open +report on the project it works around is the routine case, and a reader revisiting the workaround +needs to know whether the cause still stands. + +Move one of that carve-out's conditions and a reason comes back. An instruction document states its +constraint rather than citing a tracker for it, so the first reason holds there whatever the tracker +names. A private repository's tracker cannot be opened by a reader of the content carried into a +public repository, which is the second reason exactly. This repository's own tracker holds status +this file can state, so the first and the third hold on every surface. And a bare reference carries +no destination a reader can open at all, the hash-and-number form resolving against whichever +repository the reader happens to be in, which is why that carve-out is written as a URL. + Write the constraint the reference was standing in for, or drop the clause where the reference was the whole of its value. "A prior version re-scanned from every unmatched open, which was O(N^2)" -carries what the reader needs, and the number of the round that found it does not. - -The `issue-ref` rule in the prose gate reads the pattern-detectable half of this, an issue or pull -request reference in a comment, in a Python docstring, and in instruction text. Whatever a gate -does not reach is unlicensed all the same, since the rule binds a reader rather than a scan. - -A commit is not a shape a gate can read, since a short SHA carries the same shape as a blob id, a -version fragment, and a fixture hash, so the text above is the whole of what covers that half. A -reference in a string literal is not read either: a test builds the numbers it asserts against, and -reading those would report a fixture rather than a claim about this repository. +carries what the reader needs, and the number of the round that found it does not. Inside the +carve-out there is nothing to rewrite, the referenced thing being live status somewhere else, and +the carve-out is why that case needs no remedy rather than a remedy an author is expected to find. + +The `issue-ref` rule in the prose gate reads the pattern-detectable half of this: a bare reference +in a comment, in a Python docstring, and in instruction text, and in instruction text a URL naming +an issue or a pull request as well, written as an inline link destination, as a reference +definition, or bare in the prose. It reads one forge's URL paths, so a URL naming a tracker it does +not know is banned there and goes unreported. Whatever a gate does not reach is unlicensed all the +same, since the rule binds a reader rather than a scan. Reading the URL in instruction text is what +stops the gate reporting the bare spelling and passing the URL on the one surface it reads both, +since an author met by the bare form's finding is otherwise pointed at respelling the reference +rather than at removing it. On every other surface the gate reads the bare form alone, so a banned +URL is banned there and goes unreported. + +A bare commit reference is not a shape a gate can read, since a short SHA carries the same shape as +a blob id, a version fragment, and a fixture hash. The URL spelling of one is readable and is read +by nothing here, so the text above is the whole of what covers a commit either way. A reference in a +string literal is not read either: a test builds the numbers it asserts against, and reading those +would report a fixture rather than a claim about this repository. ## Character set diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 515d18fe7..27a968c69 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -164,7 +164,7 @@ The full comment philosophy, what earns one, structure, capitalization, growth d ### References -No comment, no docstring, and no instruction document names an issue, a pull request, or a commit, and a commit message and a pull request body each carry theirs as usual. The reasons, the surfaces the ban reaches, the narrative files it leaves alone, and what to write instead are in the `comment-and-doc-style` Skill referenced above. +No comment, no docstring, and no instruction document names an issue, a pull request, or a commit, and a commit message and a pull request body each carry theirs as usual. Two carve-outs. In a code or workflow comment, a URL naming an issue or a pull request on a public repository other than this one is a source citation and is permitted. And a record whose subject is the revision itself keeps it, which is what lets a disproved-claims entry in `.github/copilot-instructions.md` name the revision its proof was read against. Whatever neither carve-out affirmatively permits is banned by the sentence above them, which is the whole of the test. The reasons, the surfaces the ban reaches, the narrative files it leaves alone, and what to write instead are in the `comment-and-doc-style` Skill referenced above. ### Character Set diff --git a/scripts/README.md b/scripts/README.md index 6fbd81e30..7b0758811 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -55,7 +55,7 @@ The default rule set covers comment shape (`comment-wrap` and `comment-case`) al 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, and 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, 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, reporting a line holding no comment or silencing one that held several, and 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. `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. An earlier shape read the file at the diff's base to tell an authored comment from a touched one, and the rename, duplicate and prose-filter cases that opened cost four review passes, for precision the rule does not need, since 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, and the run says so on stderr rather than standing down quietly. A docstring is out of scope, as is Markdown, and so is a file whose Python does not tokenize, since the hash-anchored fallback would read a `#` inside a docstring as a comment. Where the comment is wanted, `--allow-comments` stands the rule down and names the act on stderr, and `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` and a value mirroring it turned the rule off at every commit. The composite action clears that variable and passes the flag from its own `allow-comments` input, so what decides a pull request is 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 promotion of this repository's own `develop` branch into its default branch, whose diff was reviewed where it landed. 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, and a caller reading it off the event gets the labels the run started with, so a label added after a failing run applies to the next push rather than to a re-run of that one. -The default set also carries `issue-ref`, which reports an issue or pull request reference in a comment, in a Python docstring, and in the instruction text an agent reads as law, meaning the Skills trees, the fleet's own rule documents, and `.github/copilot-instructions.md`. A documentation comment is read like any other comment here, unlike in `comment-added`, since a reference in one is the same defect, and 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, and `issue_ref_findings` 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, and a commit is not a shape a gate can read, since 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 and 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 rule shipped with the scrub that cleared the tree's existing references rather than after it, because a file whose every rationale block ended in an issue number taught the house style the rule forbids, and no rule text competes with the surrounding file until that file changes. +The default set also carries `issue-ref`, which reports an issue or pull request reference in a comment, in a Python docstring, and in the instruction text an agent reads as law, meaning 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, read off the line as written rather than off the blanked copy the bare-form scan reads, since 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 a URL naming an issue or a pull request on a public repository other than this one, while the bare spellings 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, and on every surface but instruction text the gate reads the bare form alone and a banned URL goes unreported, which 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, since 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, and 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, which 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 being 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, and 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, and 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, and `issue_ref_findings` 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, and a commit is not a shape a gate can read, since 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 and 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 rule shipped with the scrub that cleared the tree's existing references rather than after it, because a file whose every rationale block ended in an issue number taught the house style the rule forbids, and no rule text competes with the surrounding file until that file changes. A wide scan skips the trees this repo generates rather than authors, currently `reports/`, which [`spec/audit.py`][audit] writes. A finding there is the audit engine's phrasing rather than an author's, so no edit to that tree can fix it, and leaving them in made the repo's own number mostly generated output. Naming such a path directly still reads it (`prose_lint.py reports`), so nothing becomes uncheckable. @@ -99,6 +99,8 @@ 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, and 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 pairing 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, and 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, which 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, since `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, and 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: 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, and 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`, a quoted value carrying a space such as `label: "Compressor 1"`, and a key-value line with a trailing comment, each of which 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, and 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, since a PEP 723 block's `# /// script` opener draws a finding only because the key-value line under it reads as a continuation of it, and 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, which is what the count costs rather than a case the rule misses. Two is the count because the wider shape is also the shape of a marker comment, `note: obsolete` and `todo: refactor` being indistinguishable from a configuration line, and 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, which 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, and one wrapping out of a run has the line under it reported for case instead, so the author is told to capitalize a word that opens no sentence. The wider shape makes that class more reachable, since a body becomes an anchor on the wider shape where it needed the two-token one before, though only ever inside a run of two or more, where 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, so none of the three costs is 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, and 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, since a continuation whose first word takes a spaced dash is a parenthetical, which 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. **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. diff --git a/scripts/tests/test_prose_lint.py b/scripts/tests/test_prose_lint.py index 674d9aa45..c789b8a96 100755 --- a/scripts/tests/test_prose_lint.py +++ b/scripts/tests/test_prose_lint.py @@ -890,6 +890,96 @@ def test_a_sentence_wrapping_through_a_snippet_line_loses_its_wrap(self) -> None self.flag("a.yml", "# The value the hook reads is\n# empty: false\n# by default.\n"), ) + def test_a_configuration_run_is_exempt_in_the_widened_shape(self) -> None: + """The four shapes the single-token value could not reach, each as a two-line run. + + A YAML tag value, a quoted value carrying a space, and a key-value line with a trailing + comment each split on a space, so the narrow shape read every one of them as prose and + asked the author to capitalize a key a reader pastes. The fourth is the separator rather + than the value: `=` spells the same shape that `:` does, and the narrow one read neither. + """ + for label, text in ( + ("tag", "# board: !include board.yaml\n# label: friendly\n"), + ("quoted", '# label: "Compressor 1"\n# other: "Compressor 2"\n'), + ("trailing", "# sda: GPIO15 # a strapping pin\n# scl: GPIO16 # another one\n"), + ("equals", '# requires-python = ">=3.9"\n# dependencies = ["bleak"]\n'), + ): + 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. + + 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. + """ + block = ( + '# /// script\n# requires-python = ">=3.9"\n# dependencies = ["bleak"]\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)) + + 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. + + The narrow shape already carries this one, and the wider shape makes it reachable from any + unterminated key-and-rest-of-line body. A sentence wrapping into a run loses its wrap with + nothing reported, and one wrapping out of a run has the line under it reported for case. + """ + into = '# The hook reads the value from\n# label: "Compressor 1"\n# other: "Compressor 2"\n' + self.assertEqual([], self.flag("a.yml", into)) + out = ( + "# The hook reads\n" + "# note: the first value\n" + "# note: the second value\n" + "# and then stops.\n" + ) + self.assertEqual(["comment-case"], self.flag("b.yml", out)) + + def test_one_configuration_line_alone_still_reports(self) -> None: + """The exemption's accepted cost, pinned so a later change meets it rather than finds it. + + Widening the anchor hands it to a marker comment, `note: obsolete` carrying exactly the + shape of a configuration line. Two is what separates a block from a marker, so a genuine + one-line snippet in the widened shape is read as prose. + """ + self.assertEqual(["comment-case"], self.flag("a.yml", '# label: "Compressor 1"\nx: 1\n')) + + def test_prose_above_a_usage_block_is_still_read(self) -> None: + """Why the run is the configuration lines rather than the comment block holding them. + + A wrapper script opens on sentences and closes on invocation lines, all in one comment + block. Exempting the block would stop both rules reading the sentences above it. + """ + text = ( + "# The wrapper runs the installer with a\n" + "# python of any supported version.\n" + "# Run: scripts/install.sh\n" + "# Or: AGENTS_HOME=/x scripts/install.sh\n" + "x=1\n" + ) + self.assertEqual(["comment-wrap"], self.flag("a.sh", text)) + + def test_a_body_closing_as_a_sentence_is_not_a_configuration_line(self) -> None: + """The guard the narrow shape already pairs with its anchor, applied to the wider one. + + Both bodies open on a word and a colon, so the anchor alone reads them as a two-line + configuration block and exempts the pair. Each closes as a sentence does, which is what + the guard reads, so both are judged as the prose they are and both report. + """ + text = "# note: the value is read once.\n# todo: the value is read twice.\n" + self.assertEqual(["comment-case", "comment-case"], self.flag("a.yml", text)) + def test_the_exemption_holds_in_every_comment_syntax(self) -> None: """One case per syntax, so a failure names the language whose extractor broke. @@ -4798,8 +4888,14 @@ def test_one_reference_on_one_line_reports_once(self) -> None: self.kinds("a.py", 'def f() -> None:\n """Body #1011.""" # per #1011\n'), ) - def test_a_link_destination_is_not_a_reference(self) -> None: - """An anchor to a heading that opens on a number is the shape, and a destination is not prose.""" + def test_an_anchor_to_a_heading_is_not_a_reference(self) -> None: + """A heading that opens on a number anchors to a link destination carrying the same shape. + + The blanking that hides it is also what hid every URL spelling of a real reference, which + the forge pattern reads off the unblanked line instead. A destination naming no tracker is + untouched by that pattern, so both halves hold at once: the anchor passes and the link text + beside it still reports. + """ self.assertEqual([], self.kinds("AUDIT.md", "[section 10](#10-converge-apply-the-fixes)\n")) self.assertEqual([], self.kinds("AGENTS.md", "[s]: #10-converge\n")) self.assertEqual( @@ -4807,6 +4903,49 @@ def test_a_link_destination_is_not_a_reference(self) -> None: self.kinds("GOVERNANCE.md", "See [#1011](https://x.invalid/pull/1011).\n"), ) + def test_one_reference_on_one_line_is_one_finding_in_either_spelling(self) -> None: + """The line is the unit, and a link carries the same reference twice over. + + The destination names it and the link text spells it bare, so a key on the matched text + reports one reference as two. The number both patterns capture is the key instead. + """ + url = "https://github.com/acme/widget/issues/4242" + self.assertEqual(["issue-ref"], self.kinds("CODESTYLE.md", f"See [#4242]({url}) today.\n")) + self.assertEqual( + ["issue-ref", "issue-ref"], + self.kinds("CODESTYLE.md", f"See [#4243]({url}) today.\n"), + ) + + def test_a_service_subdomain_is_not_the_forge_host(self) -> None: + """The tracker is served from the bare host, so a service beside it is a different service. + + `www` is that host's own alias rather than a service of its own, which is why it reads as + the host and the two constructed service names below do not. + """ + for url in ( + "https://docs.github.com/acme/widget/issues/4242", + "https://raw.github.com/acme/widget/issues/4242", + ): + with self.subTest(url=url): + self.assertEqual([], self.kinds("CODESTYLE.md", f"See {url} today.\n")) + self.assertEqual( + ["issue-ref"], + self.kinds("CODESTYLE.md", "See https://www.github.com/acme/widget/pull/4242 now.\n"), + ) + + def test_two_references_sharing_a_number_on_one_line_report_once(self) -> None: + """The digit key's own cost, pinned so a later change meets it rather than finds it. + + Two projects number their trackers independently, so one line can name two references under + one number and the key cannot tell them apart. The second reports on the run after the first + is fixed, which is a round rather than a lost detection, and it is written down either way. + """ + text = ( + "See https://github.com/acme/widget/issues/4242 and " + "https://github.com/other/thing/issues/4242 now.\n" + ) + self.assertEqual(["issue-ref"], self.kinds("CODESTYLE.md", text)) + def test_only_the_skill_roots_are_instruction_text(self) -> None: """A product with a `skills/` directory of its own is not carrying fleet law in it.""" for name in ("src/skills/notes.md", "docs/skills/guide.md", "skills/top.md"): @@ -4817,6 +4956,50 @@ def test_only_the_skill_roots_are_instruction_text(self) -> None: self.kinds(".claude-plugin/fleet-skills/skills/x/SKILL.md", "Shipped in #1011.\n"), ) + def test_the_link_spelling_is_read_in_instruction_text(self) -> None: + """Every link form of one reference, each of which passed while the bare form reported.""" + url = "https://github.com/acme/widget/pull/4242" + for label, text in ( + ("inline", f"The [pull request]({url}) landed.\n"), + ("bare", f"See {url} for the history.\n"), + ("definition", f"The [pull request][pr-a] landed.\n\n[pr-a]: {url}\n"), + ("issues", "See https://github.com/acme/widget/issues/4242 today.\n"), + ): + with self.subTest(form=label): + self.assertEqual(["issue-ref"], self.kinds("CODESTYLE.md", text)) + + def test_a_link_naming_another_tracker_is_kept_in_code(self) -> None: + """The surface split the rule turns on: in code the link is the citation for the line.""" + text = ( + "# Raise the frequency to clear the timeout.\n" + "# https://github.com/acme/widget/issues/4242\n" + "frequency: 400kHz\n" + ) + self.assertEqual([], self.kinds("a.yml", text)) + + def test_a_link_in_a_narrative_document_is_kept(self) -> None: + """A tracker, a history and a README exist to carry exactly these references.""" + text = "Landed in https://github.com/acme/widget/pull/4242 upstream.\n" + for name in ("README.md", "HISTORY.md", "TODO.md"): + with self.subTest(name=name): + self.assertEqual([], self.kinds(name, text)) + + def test_a_fenced_link_is_quoted_rather_than_stated(self) -> None: + """The same bound the bare form already carries, a fence quoting content.""" + text = "Text.\n\n```\nhttps://github.com/acme/widget/pull/4242\n```\n" + self.assertEqual([], self.kinds("CODESTYLE.md", text)) + + def test_a_link_to_something_other_than_a_tracker_is_not_a_reference(self) -> None: + """The path decides it, so a link to a file, a release or a repository is untouched.""" + for url in ( + "https://github.com/acme/widget", + "https://github.com/acme/widget/blob/main/README.md", + "https://github.com/acme/widget/releases/tag/4242", + "https://example.com/acme/widget/pull/4242", + ): + with self.subTest(url=url): + self.assertEqual([], self.kinds("CODESTYLE.md", f"See {url} today.\n")) + def test_the_rule_gates_a_pull_request_rather_than_waiting_to_be_named(self) -> None: self.assertIn("issue-ref", prose_lint.RULES) self.assertIn("issue-ref", prose_lint.DEFAULT_RULES) From efb021cd8d01d2bde7beecc3e17014daaacc6fe0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 20 Sep 2026 17:37:37 -0700 Subject: [PATCH 2/2] Say Why the URL Form Reads a Number the Bare Form Cannot A review round read the two patterns side by side and took their differing digit bounds for an oversight. They disagree deliberately. Each of the bare form's bounds trades a detection for a shape that form is confusable with, and the block above it sets both out. A path segment after the tracker's own `/issues/` or `/pull/` is confusable with nothing, so it trades neither bound and reads a number the bare form is bounded out of. Measured across lengths one through eight: the URL form matches every one of them, and the bare form matches two through five alone. The code never said that, which is what drew the finding, so it says it now. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/prose-gate/prose_lint.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index e23482837..893c9451a 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -789,6 +789,10 @@ def is_operations_runbook(path: Path, root: Path | None) -> bool: # The digits are captured so a line naming one reference twice over reports it once. # That key collapses two different references sharing a number on one line as well. # The second is reported once the first is fixed, so it is a round rather than a lost detection. +# Those digits are unbounded in length, where `ISSUE_REF` bounds its own at two through five. +# Each of that pattern's bounds trades a detection for a shape the bare form is confusable with. +# A path segment after `/issues/` or `/pull/` is confusable with nothing, so it trades neither. +# The two disagree deliberately, and the URL form reads a number the bare form is bounded out of. FORGE_REF = re.compile( r"https?://(?:www\.)?github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/([0-9]+)", re.IGNORECASE,