Carry a multi-line string in every syntax, and answer the router-split items - #473
Conversation
…t items Closes #466. The masker carried a string past the end of a line for the C# verbatim form only, so a shell quote, a heredoc, a PowerShell here-string, and a YAML block scalar each left their markers readable and the comment rules reported on string content. Each syntax now declares the forms it carries, and the whole-line forms are resolved before the line is scanned. A YAML `run:` scalar is deliberately excluded: it holds a script whose `#` lines are the comments the rule governs, so treating it as data would drop real coverage. The reverse direction is guarded too, since a form that carried where the language has none would blank the rest of the file and report nothing. Closes #471. Its seven source-side items: 1. The no-coordination-references rule justified itself with the template repo being private. It is public, so the reason is recast onto the two grounds that hold. 2. `OPERATIONS.md` is recorded in the section model, with the topical docs named and the content each one takes. 3. `GOVERNANCE.md` is added to the reference-style-links exception list, which is what its 51 inline links reflect, and the exception is stated as a closed list rather than a category to argue from. 4. `OPERATIONS.md` joins that list as agent-instruction content. 5. The clause-splicing semicolons are swept out of the verbatim sections, all 32 of them, and the governing rule is recorded: correct-as-you-next-edit has no downstream trigger for verbatim content, so the hub sweeps the class. 6. `spec/audit.py --branch` reads a ref other than the registry's `groundTruthBranch`, so a convergence is verifiable before it is promoted. A ref that does not resolve is one error naming it rather than a baseline's worth of file-absent letters. 7. The Financial-Modeling driftNotes are reconciled against a live audit. `status` stays `cataloged`, which is the terminal value the schema offers. Both offered rules are promoted into Verification Discipline, and the distinctive-phrase migration guidance is recorded in the section model. Along the way: `scripts/test_pr_review.py` covers the one script that had no tests, `changed_lines` and the `sentence-split` rule get their first cases, and `spec/audit.py --selftest` now runs in CI. Coverage over `scripts/` goes from 87% to 99%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the repo’s prose/comment linting and governance/audit tooling to (1) correctly treat multi-line string forms as opaque across all supported syntaxes (avoiding false-positive comment findings inside string data), and (2) incorporates the remaining source-side improvements identified during the router split convergence, including stronger verification rules and substantially expanded test coverage.
Changes:
- Extend
prose_lint’s string masking to carry multi-line string state across lines for shell (quotes + heredocs), PowerShell (quotes + here-strings), YAML (block scalars, excludingrun:), and C# (verbatim strings). - Add
spec/audit.py --branch REFand tighten audit behavior for unresolvable refs (single error instead of baseline-wide noise), with corresponding self-tests. - Add/expand unit tests across
scripts/and wire them into CI validation; reconcile and refine governance/docs/registry prose per the router-split findings.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
spec/section-model.md |
Records repo-specific extraction destinations (including OPERATIONS.md) and migration guidance for the router split. |
spec/audit.py |
Adds --branch override, replaces ad-hoc argv parsing with argparse, resolves branch refs early, and expands self-tests. |
scripts/test_repo_gate.py |
Adds CLI-oriented coverage and additional harness assertions for repo gate behavior. |
scripts/test_prose_lint.py |
Adds extensive multi-line string, syntax-dispatch, sentence-split, discovery, and diff-scope tests. |
scripts/test_pr_review.py |
New test module covering pr_review.py parsing, polling correctness, and transport/read-only contract. |
scripts/README.md |
Updates script documentation and test invocation list; aligns link style with updated governance exceptions. |
scripts/prose_lint.py |
Implements carried multi-line string masking across syntaxes; extends syntax metadata; improves rule comments. |
scripts/pr_review.py |
Makes CLI entrypoint testable by accepting argv; clarifies liveness-query comments. |
registry/repos.json |
Reconciles drift notes for Financial-Modeling to reflect the completed convergence. |
README.md |
Updates backlog item to reflect follow-on “dash” sweep scope as intentionally separate. |
GOVERNANCE.md |
Applies router-split source-side rule fixes, clarifications, and promoted verification-discipline rules. |
AUDIT.md |
Documents spec/audit.py --branch as a supported verification workflow for pre-promotion convergence checks. |
.github/workflows/validate-task.yml |
Runs the expanded script self-tests and spec/audit.py --selftest in CI. |
.gitattributes |
Pins LF for the new scripts/test_pr_review.py module. |
Suppressed comments (1)
scripts/prose_lint.py:400
- PowerShell here-strings require the closing token (
"@/'@) to start at column 0. Usingline.lstrip()allows an indented closer to terminate the here-string early, which can re-enable comment scanning on lines that are still part of the here-string body.
if carry.kind == 'here':
body = line.lstrip()
if not body.startswith(carry.text):
return carry, None
return CLEAR, len(line) - len(body) + len(carry.text)
Copilot's round-1 findings, all three the same class: a terminator matched after stripping indentation ends the string on a line that is still its content, which resumes comment scanning inside the data. A heredoc's terminator sits at column 0 under a plain `<<`, and `<<-` strips leading tabs and nothing else, so the dash is carried on the open and compared against on the close. A PowerShell here-string's closer likewise has to start the line. The mutable default in the test payload helper is replaced with None. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 1 answered, including the suppressed findingBoth inline threads are fixed in b1a2cc8 and replied to on the thread. The suppressed one has no thread to reply on, so it is answered here. Suppressed:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/prose_lint.py:211
- For YAML, single-quoted scalars treat backslash as an ordinary character (no backslash escapes). With
rawleft empty here,strip_strings()will treat a backslash before the closing'as escaping it, which can incorrectly keep the string “open” and mask any#comment marker later on the line.
Set YAML’s raw to include the single quote so single-quoted YAML strings use the doubled-quote escape rule instead of backslash escaping.
# A YAML block scalar is the multi-line form.
# A plain scalar's apostrophe is not a string, so an ordinary quote must not carry here.
YAML: Syntax = {**HASH, 'carry': frozenset({'block'})}
Copilot's round-2 suppressed finding. Both languages spell the escape in a single-quoted scalar as a doubled quote and take no backslash escape, so a value ending in a backslash consumed its own closing quote and blanked the rest of the line, hiding a trailing comment. Both keep the backslash escape in the double-quoted form, so the two are read differently in the same file, and the cases pin each direction. TOML takes a syntax entry of its own for the same reason rather than staying on the shared hash spec, since the finding is one instance of that class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 answeredNo new inline comments. One suppressed finding, which has no thread, so it is answered here. Suppressed:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/prose_lint.py:320
strip_strings()does not handle PowerShell’s backtick escape inside double-quoted strings. With the newPOWERSHELLspec marking"as a raw (doubled-quote) form, an escaped quote like"a`"b"will be misread as closing the string at the escaped", which can re-expose comment markers that are still inside string content.
elif doubled:
if ch == quote and line[i + 1:i + 2] == quote: # a doubled quote is one character
out[i] = out[i + 1] = ' '
i += 2
continue
Copilot's round-3 suppressed finding: PowerShell escapes with a backtick, and marking its double-quoted form raw made an escaped quote close the string early, re-exposing markers that are still inside it. The two properties were conflated behind one flag. A string that embeds its delimiter by doubling it and a string that reads one character as escaping the next are separate facts, and neither implies the other: PowerShell's double-quoted form is both at once, while a C# verbatim string is doubling and not escaped. Each is now read per string as it opens, from `raw` and from the `escape` / `escape_in` / `escape_out` triple, which also subsumes the shell outside-a-string backslash the earlier commit added as a one-off. Both failure directions are pinned per language: reading an escape a string does not have consumes its closing quote and blanks the rest of the line, and missing one it does have ends the string on the escaped quote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 answeredNo new inline comments. One suppressed finding, answered here since it has no thread. Suppressed:
|
| Syntax | Doubling | Escape |
|---|---|---|
| C-like, XML, CSS, INI | none | \ inside either quote |
C# verbatim (@") |
"" |
none |
shell ' / " |
'' on the single |
\ inside the double, and outside a string |
PowerShell ' |
'' |
none |
PowerShell " |
"" |
` inside, and outside a string |
YAML, TOML ' / " |
'' on the single |
\ inside the double |
Both failure directions are pinned, per language, because they fail oppositely: reading an escape a string does not have consumes its closing quote and blanks the rest of the line, while missing one it does have ends the string early on the escaped quote. The PowerShell cases now cover "a`"b", "a""b"``, ``'a'``, 'a''b', and `"C:\tmp"`, and the existing C# verbatim cases still hold the other side of the table. Two table assertions were added as well: the escape is one character, it is never also a quote, and a syntax cannot declare an escape with nowhere for it to apply.
Verified no collateral change: the tree-wide comment-wrap / comment-case finding set is identical outside the two files this commit touches. Full local gate run green on the new head: 155 cases, spec/audit.py --selftest, spec/validate.py, scripts/repo_gate.py, the prose gate, mypy, ruff (unchanged count), markdownlint, cspell, actionlint, editorconfig-checker.
Documented in scripts/README.md alongside the carrying rules, since the next person adding a syntax needs both.
The rule already warned that a text-mode rewrite silently flattens CRLF to LF. It did not say that in Python that is the default path: `read_text` decodes through universal newlines and `write_text` writes `\n` back, so a scripted edit flattens the file while the edit itself reads correctly, and only the diff stat shows it. Naming the two-word fix is what makes the rule actionable at the moment someone is about to break it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (2)
scripts/prose_lint.py:213
rawis documented/used as the set of quote delimiters that escape an embedded delimiter by doubling it (handled via thedoubledbranch instrip_strings). Shell single quotes do not escape a delimiter by doubling, so treating'as raw makes balanced constructs like'it''look unterminated and then carry across lines, which can incorrectly suppress comment scanning for the rest of the file.
# A shell single-quoted string takes no escape at all, and either quote form spans lines.
# Outside a string a backslash escapes the next character, which is how `'\''` embeds a quote.
# A heredoc runs from its label to the line that repeats it.
SHELL: Syntax = {**HASH, 'raw': "'", 'escape_in': '"', 'escape_out': True,
'carry': frozenset({'quote', 'label'})}
scripts/prose_lint.py:375
SCRIPT_SCALARdoesn’t account for YAML block-scalar chomping/indent indicators (e.g.run: |-,run: |2). Those headers matchBLOCK_SCALARbut notSCRIPT_SCALAR, so they’ll be treated as opaque data even though the intent is thatrun:blocks remain script-comment-scanned.
BLOCK_SCALAR = re.compile(r'(?::|^\s*-)\s*[|>][+-]?\d?\s*$')
# `run:` holds a script rather than data, so its `#` lines are comments this rule governs.
SCRIPT_SCALAR = re.compile(r'(?:^|\s)run:\s*[|>]')
Copilot's round-5 suppressed finding. `raw` means a string that embeds its delimiter by repeating it, and a shell single-quoted string cannot embed its delimiter at all, so the declaration stated something false about the language. What it was standing in for, that a backslash is not an escape there, is already carried by `escape_in`, so the entry comes out and the behavior is unchanged. The failure the finding predicted does not occur: doubling and plain toggling agree on whether a run of quotes leaves a string open, checked exhaustively over every string of up to eight quote, word, and marker characters. The readings differ only in which quotes survive masking, which is what the new case counts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 5 answered: one accepted, one declined with evidenceNo inline comments. Two suppressed findings, both against 1. Accepted, with the reasoning corrected: shell single quotes declared as
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/prose_lint.py:580
headis captured only once asmasked[:at](the code before the first comment marker on the line). If the line contains a closed block comment and then code that opens a carried multi-line form (notably PowerShell<# ... #> @"/@'here-strings),opened_string(spec, head, line)never sees the opener, so the carry state is dropped and markers inside the here-string can be misclassified as comments.
Consider deriving head as a length-preserving “code-only” view of the entire line (blanking comment spans, but retaining code after same-line closed block comments) before calling opened_string at the end of the loop.
if not scanned:
head, scanned = masked[:at], True # the code this line opens a string from
if found is None:
carry = tail_state # the rest of the line is code
Copilot's round-6 suppressed finding. A line's code was captured once, as the text before its first comment marker, so a string opened by code sitting after a block comment that closed on the same line was never seen. `<# Note. #> $s = @"` in PowerShell is the case: the here-string does not open, the trailing quote falls back to an ordinary carried one instead, and the first quote in the here-string body closes it, so comment scanning resumes inside the string. The same file without the leading block comment reads correctly, which is what the new case contrasts. The code view is now built up across the scan, one span per iteration, still length-preserving so a heredoc label is read off the raw line at the same offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 7 clean, and verifying that turned up a runbook defectRound 7 reviewed the current head and generated no inline comments and no suppressed block. That is the first round of the seven with nothing in either channel. The runbook could not see the suppressed findings it exists to catchChecking the round-7 body the documented way returned nothing for every round, including the five that each carried a real suppressed finding:
Fixed in 3d13948. The filter is a gh api repos/<owner>/<repo>/pulls/<N>/reviews --jq \
'.[] | select(.body | test("Suppressed comments|low confidence")) | .body'The prose now says to match on more than one phrasing and why, rather than naming one heading that will move again. I caught this only because I had been reading every review body in full rather than trusting the filter. Worth stating plainly: the findings on this PR were reached by reading, not by the documented command. Had I relied on the command, rounds 1 through 6 would have looked clean and five real defects would have shipped, including the two the earlier rounds found in code written to fix the round before it. This is the seventh push, so a review on this head is what closes the loop. Full local gate run green: 157 cases, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/prose_lint.py:396
run:YAML block scalars are treated as “script, not data” by skipping the YAMLblockcarry, but the lines inside are still scanned using the YAML string rules (YAML['carry']is{'block'}), so shell multi-line quotes/heredocs inside arun: |script won’t carry across lines. That can still producecomment-wrap/comment-casefindings on string content inside workflow scripts (e.g., an unclosed shell quote containing a# ...line). Consider adding a dedicated “run-script” mode that parsesrun:scalar bodies with shell string/carry rules (quote + heredoc) while still treating#as the comment marker, instead of falling back to YAML string semantics line-by-line.
if 'block' in spec['carry'] and BLOCK_SCALAR.search(head) and not SCRIPT_SCALAR.search(head):
return Carried('block', '', len(line) - len(line.lstrip()))
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (1)
spec/audit.py:511
- When the configured ground-truth branch does not exist, the function returns early before emitting the already-known branch-fact findings (e.g.,
branch: main does not exist, orhasDevelopdrift). Those are still useful diagnostics even if no content can be read, and they were already fetched above.
ground_head = {"main": branch_main, "develop": branch_dev}.get(ground)
if ground_head is None:
ground_head = gh(f"repos/{slug}/branches/{ground}", ok404=True)
if ground_head is None:
return findings + [("ERROR", f"branch: ground-truth branch {ground} does not exist, so nothing could be read")], ""
Copilot's round-8 suppressed finding. The early return for a ground-truth branch that does not resolve sat above the two branch-fact findings, so a repo that tripped it lost a `hasDevelop` drift and a missing-main defect that were already fetched and still true. The point of the early return is to skip the content reads that would 404, not to discard what the branch reads already answered. The selftest fixture now declares `hasDevelop: true` against a repo with neither branch, so it asserts the exact finding sequence rather than only the error count, which is what would have caught this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 8 answeredNo inline comments. One suppressed finding, and it is right. Suppressed:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/prose_lint.py:351
- In YAML (and similar), apostrophes inside plain scalars (e.g.
key: don't # comment) are not string delimiters, butstrip_stringstreats any'as opening a (raw) string. This masks everything after the apostrophe and can hide real trailing#comments on the same line.
elif ch in quotes:
quote = ch
# An interpolated one is spelled either way round, so read the whole prefix.
# Only the double-quoted form has a verbatim spelling, so a char literal is ordinary.
start = i
Copilot's round-9 suppressed finding. A quote was read as a delimiter wherever it sat, so a plain scalar's apostrophe opened a string that ran to the end of the line and took a real trailing comment with it. `key: don't # note` reported nothing, and this repo's own workflow YAML carries the pattern. YAML delimits a scalar with a quote only at the start of a value, so `quote_after` names what one must follow: the `:`, the sequence dash, or a flow indicator. It is empty everywhere else, since a C# string opens after `=`, `(`, or `,` and applying the restriction there would stop masking C# strings at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 9 answeredNo inline comments. One suppressed finding, and it is right. Suppressed:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/prose_lint.py:399
SCRIPT_SCALARis too broad:re.compile(r'(?:^|\s)run:\s*[|>]')will also match keys that merely containrun:after whitespace (e.g.dry run: |), causing those block scalars to be treated as scripts instead of opaque data. That can reintroduce false positives inside non-runblock scalars.
Consider anchoring to the start of the YAML key (with optional list-item dash) so only the actual run: field is exempted.
# `run:` holds a script rather than data, so its `#` lines are comments this rule governs.
SCRIPT_SCALAR = re.compile(r'(?:^|\s)run:\s*[|>]')
Copilot's round-10 suppressed finding. The pattern matched `run:` after any whitespace, so a YAML key merely ending in the word - `dry run: |`, valid YAML for a key with a space in it - was exempted from opacity and its data was scanned as a script. Anchoring to the key position keeps every real form, the bare field and the list-item `- run:` a workflow step uses, and drops the accidental ones. The cases now cover both directions, since the round-5 answer established that the chomping and indent indicators ride along on the header and that pairing was never pinned either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 10 answeredNo inline comments. One suppressed finding, and it is right. Suppressed:
|
Round 11 clean in both channels: the loop is closedRound 11 reviewed head
Ready for review and squash. Not merged, deliberately - that is the maintainer's call. What the eleven rounds cost and bought
Eleven defects fixed, one finding disproved. Only round 1 produced an inline thread. Everything after it came out of the suppressed block, so a loop that polls Three of the eleven were in code written to fix the round before it. One (round 8) was in a case I had cited in an earlier answer as evidence, which passed while two findings silently vanished. The finding that mattered most was about the process, not the codeRound 7 was clean, and checking it the way I found the ten defects by reading every review body in full rather than trusting that filter. Had I trusted it, this PR would have shipped them. Two contradictory findings on one line
Final state11 commits, 15 files. 159 test cases over Merging re-vendors 12 |
#474) Closes #466. Closes #471. Promotes one squash commit, `f7c77d8` (#473). Clean forward merge, no conflicts, nothing main-only in the content diff. ## What lands on `main` **`prose_lint` carries a multi-line string in every syntax that has one.** It carried past the end of a line for the C# verbatim form only, so a shell quote, a heredoc, a PowerShell here-string, and a YAML block scalar each left their markers readable and the comment rules reported on string content. Each syntax now declares what its strings actually do: which quotes delimit and where, which embed the delimiter by doubling it, how they escape, and which forms survive a newline. A YAML `run:` scalar is deliberately not opaque, since it holds a script whose `#` lines are exactly the comments the rule governs. **The seven source-side items from the Financial-Modeling router split**, including the recast coordination-reference justification (the template repo is public, so the 404 reason was false), `OPERATIONS.md` in the section model and in the inline-link exception list, `GOVERNANCE.md` added to that list, `spec/audit.py --branch` for verifying a convergence before promotion, and the reconciled Financial-Modeling driftNotes. **Both rules that repo offered are promoted** into Verification Discipline, and the distinctive-phrase migration guidance is recorded in the section model. ## This is a fleet re-vendor Twelve `GOVERNANCE.md` verbatim sections change, so every carried repo goes stale against the canonical on merge: | Change | Sections touched | | --- | --- | | The clause-semicolon sweep (32 instances) | Branching Model, Release Model, Documentation Style Conventions, PR Review Etiquette, Communicating with the User, Workflow YAML Conventions, Supported Development Platforms, Editor and Tasks, Repository Details, Repository Onboarding and Conformance | | Two promoted rules plus the CRLF mechanism | Verification Discipline | | The reference-style exception list and the recast justification | Documentation Style Conventions | | The verbatim-sweep rule | Documentation Style Conventions | That drift wave is the mechanism working, not a regression. The re-vendor is not tracked by an issue yet, and filing one is not authorized. ## Verification `develop` CI is green on `f7c77d8`, and every gate passes locally on the merged state: 159 test cases over `scripts/`, `spec/audit.py --selftest`, `spec/validate.py`, `scripts/repo_gate.py`, the prose gate, markdownlint, cspell, actionlint, editorconfig-checker, and mypy. #473 took **eleven Copilot rounds and fixed eleven defects**, only the first of which arrived as an inline thread. Round seven exposed why: the runbook's documented command for reading the suppressed-findings block keyed on a phrase the current review format does not use, so it matched none of the five rounds that carried one. That fix is in this promotion. No release fires from this merge: a human promotion to `main` skips the publisher's actor gate. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Copilot's review found the first half: suppressed= counted blocks, so a body heading "Suppressed comments (2)" reported 1 and read as a single finding. The count now comes from the heading's (N), summed across the rounds on the head, floored at one so a block never reports as zero and reads as a clean pass. Running the tool on its own PR found the second half. The match was against the whole body, so this PR's review, whose overview prose discusses suppressed and low confidence findings while carrying none, reported suppressed=1. The match is now on the block's heading, taken from its <summary>. Prose that merely names the phrase is not a finding, and a field that cries wolf trains the reader to skim it. The markup-moved fallback survives, since reporting zero when the wrapper changes is the same false clean, but it now reads only the text outside the details blocks and requires a heading carrying a count. Verified against the live bodies on both PRs: #473 reads one finding from its real block, and #477 now reads zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge gate counts a review body's collapsed low-confidence block as an outstanding finding, but the digest read reviewThreads alone, where that block never appears, so the one command written to answer "is this PR clean" reported clean while findings stood. Q_FULL now carries each review's body, and the digest reports suppressed=N for the blocks on the current head. Q_LIVE is untouched, so a liveness poll still fetches two scalars. Each block prints whole where a thread body truncates at 160, since a thread can be re-read at its id and a suppressed finding has no thread. Two defects were found and fixed inside the review loop. Copilot caught that the field counted blocks rather than findings, so a heading reading "Suppressed comments (2)" reported 1; the count now comes from the heading's (N), summed across the rounds on the head and floored at one. Running the tool on its own PR caught the second: the phrase was matched against the whole body, so a review whose overview prose discusses suppressed findings while carrying none reported one. The match is now anchored to the block's summary heading, and the markup-moved fallback reads only the text outside the details blocks and requires a heading with a count. Verified against live review bodies rather than fixtures alone: #473 reads one finding from its real block and #477 reads zero, agreeing with the runbook's independent REST query. The scripts README documents the behavior, and its own spaced hyphens are corrected in passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…479) Forward promotion of four commits. `main` carries no content beyond the merge-base (`f7c77d8`, #473), so this is a clean forward merge with no conflicts. ## What promotes - **#475 - Gate US English spelling where cspell does not reach.** The cspell gate reads README and HISTORY only, so a British spelling anywhere else in the tree had nothing checking it. The `spelling` rule generates its banned words from stems rather than listing them one by one, since an inflected spelling is as wrong as its base and a hand-listed family drifts. - **#476 - Sweep the spaced-hyphen prose class out of the carried docs.** All seven carried documents to zero: `GOVERNANCE.md` 219, `CODESTYLE.md` 53, `WORKFLOW.md` 51, `.github/copilot-instructions.md` 40, `AUDIT.md` 29, `repo-config/README.md` 20, `AGENTS.md` 3. The remaining ~538 are hub-only (`reports/`, `docs/`, `spec/*.md`, `STANDUP.md`, `catalog/`), stay warn-only, and are corrected as each file is next edited, which works because nothing vendors them. - **#477 - Report the suppressed findings the thread poll cannot see.** The merge gate counts a review body's collapsed low-confidence block as an outstanding finding, but the digest read `reviewThreads` alone, where that block never appears, so the one command written to answer "is this PR clean" reported clean while findings stood. - **#478 - Sweep the semicolon prose class out of the carried docs.** The exemption is fixed first (list-wide comma, per-table-cell scoping, bullet-label colon), then 44 genuine clause joins recast across five files. All six remaining carried files reach zero, the bar `GOVERNANCE.md` already met. ## Verification on the merged `develop` Both prose classes report zero across all seven carried documents. The full 180-case script suite, `scripts/repo_gate.py`, `spec/validate.py`, and `spec/audit.py --selftest` pass. The blocking prose run (charset, dupword, spelling) is clean tree-wide, markdownlint reports 0 issues over 39 files, and editorconfig-checker is clean. cspell's gated scope (README + HISTORY) is clean; the tree-wide backlog is unchanged. ## Release A human merge never auto-publishes, so this fires no release, as designed. The latest release stays `2.0.108`. ## Re-vendor debt This promotion changes prose in all seven carried documents, so every downstream repo goes further out of date against canonical. That debt is pre-existing and still untracked - no issue covers it and filing one has not been authorized. `spec/audit.py --branch <ref>` checks a repo's convergence before it promotes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes #466. Closes #471.
#466 - a multi-line string carries in every syntax
strip_stringscarried state past the end of a line for the C# verbatim form only. Every other syntax was masked a line at a time, so a string that spans lines left its markers readable andcomment-wrap/comment-casereported on string content, which is the false-positive direction: the rule asks a reader to edit text that is data.Each syntax now declares two things:
raw, the quotes whose strings take no backslash escape, andcarry, the forms that survive a newline. The whole-line forms (a heredoc, a here-string, a block scalar) are resolved before the line is scanned for a marker, and the line that closes one gives back whatever follows the closer, the way a closing quote already did.<<EOF,<<-EOF,<<'EOF', stacked on one line)@"to"@,@'to'@)|,>, with chomping and indent indicators), ended by dedentTwo decisions worth review:
run:scalar is deliberately not opaque. It holds a script whose#lines are exactly the comments this rule governs, so treating every block scalar as data would have silently dropped coverage of every workflow script in the fleet. A data key (files:,tags:,build-args:) is opaque.Also fixed while implementing: in shell a backslash outside a string escapes the next character, which is how
'\''embeds a quote. Without that the idiom leaves one quote open, and with carrying now on, that would have gone quiet for the rest of the file.Verified no behavior change elsewhere: the tree-wide
comment-wrap/comment-casefinding set is identical before and after, apart from the files this PR edits.#471 - the seven source-side items
OPERATIONS.mdis in the section model. A new "Where repo-specific content goes" section names the three topical docs and what each takes, so extraction has a predictable target instead of a per-repo judgment call. The README backlog item that asked for this is retired.GOVERNANCE.mdhas 51 inline links and no reference definitions, so it is added to the exception list, which is what its actual shape reflects. The exception is now stated as a closed list rather than a category, so the next repo does not have to re-derive whether its file qualifies.OPERATIONS.mdjoins that list, as agent-instruction content extracted out ofAGENTS.md.GOVERNANCE.md;AGENTS.mdwas already clean. The governing rule is recorded with it: correct-as-you-next-edit assumes someone able to edit the file, and a downstream repo cannot edit a verbatim section, so the hub sweeps the class rather than waiting for an edit that can never happen there. Scoped explicitly to verbatim content, sointent-fidelity files are still corrected as they are next edited.spec/audit.py --branch REFreads that ref instead of the registrygroundTruthBranch, so a convergence is verifiable before it is promoted, with no registry edit. The hand-rolled argv parse is replaced withargparsebecause the old one took every non---argument as a repo name, so--branch developwould have audited a repo named "develop". A ref that does not resolve is one error naming it, rather than every?ref=read 404ing into a baseline's worth of file-absent letters.main. One correction to the report:statusstayingcatalogedis not stale. The registry schema offers onlycatalogedandbacklog, and all 21 repos arecataloged, so there is no converged state to move to.Both offered rules are promoted into "Verification Discipline", where the other silent-failure rules live: config with a uniqueness rule is validated on read, and validate-and-read on the same normalized key. Neither was present in the canonical (grepped for uniqueness, duplicate, normalize, lookup, precedence across all four rule books before adding).
The triage-method note is recorded in the section model as "Migrating a repo onto the split": probe the canonical for each local rule's distinctive phrase, because a word-overlap heuristic scores a repo-specific rule written in ordinary governance vocabulary as a reworded duplicate, and it is confidently wrong in the direction that loses content.
Coverage and tests
scripts/test_pr_review.pyis new.pr_review.pyhad no test module at all whilescripts/README.mdclaimed every script had one. Its failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, a maintainer's own thread read as a finding, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, and one asserts no mutation has crept into a read-only script.changed_lineshad zero tests. It is the whole mechanism behind--diffscoping, and a parse that returns too little makes the warn-only CI step silently stop reporting.sentence-splitrule had zero tests.spec/audit.py --selftestis offline and was never run by CI. It runs there now, with new cases for the CLI parse, the branch override, and the unresolvable-ref path.scripts/: 87% to 99%, 104 to 150 cases.Not in scope, deliberately
dashclass. Item 5's argument applies to it identically, but it is roughly 350 instances acrossGOVERNANCE.md,CODESTYLE.md,WORKFLOW.md, andAUDIT.md, which is a prose rewrite that wants its own review rather than riding along here. Recorded in the README backlog..gitattributes' own comment backlog. The change to that file is a single data line (the LF pin for the new test module), and reworking its 14 comment findings would bury the diff. The standing correct-as-you-edit policy covers it..markdownlint-cli2.jsoncdrift the Financial-Modeling audit reports. That is a downstream re-vendor, not a hub change.Verification
Every gate run locally and green: the 150-case suite,
spec/audit.py --selftest,spec/validate.py,scripts/repo_gate.py,prose_lint --check charset --check dupword, markdownlint over**/*.md, cspell, actionlint, editorconfig-checker, mypy, and ruff (unchanged finding count on every touched file). CRLF preserved on every CRLF file, LF on every pinned one, verified by byte scan.Note that merging this re-vendors 12
GOVERNANCE.mdverbatim sections across the fleet. That drift wave is the mechanism working.🤖 Generated with Claude Code