From 0698192afd9779e111402130ebf0ab60b9b5d6e1 Mon Sep 17 00:00:00 2001 From: iancooper Date: Sat, 22 Aug 2026 13:53:50 +0100 Subject: [PATCH] =?UTF-8?q?spec:=20010=20Task=2011.8=20=E2=80=94=20the=20s?= =?UTF-8?q?econd=20H1,=20and=20the=20rule=20that=20could=20not=20see=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildingAnAsyncPipeline.md carried two H1s, lines 1 and 8. NO H1 fires only on none and rule 3b starts at `##`, so nothing checked it. The demotion is its own argument for the rule. What a second H1 buys its author is exemption from every rule a section heading obeys: as `# Implementing a Pipeline` it was fine, and as `## Implementing a Pipeline` it collides with BuildingAPipeline.md:52, which carries that heading already. It is now `## Implementing an Async Pipeline`, unique and qualified. Nothing linked the old anchor, checked before the edit. EXTRA H1 is an error repo-wide from this commit, which the corpus permits: one page had it and it is fixed here, so the rule lands at zero — the same shape as rule 4 after Task 7.2 cleared its debt. CLAUDE.md's ledger gains the row in the same commit. Enumerating the ledger for that row found the gap it exists to prevent, for the third time and in the third direction. Spec 011 found NO H1 in the linter and not in CLAUDE.md; Phase 10 found it in CLAUDE.md and not in the linter's docstring, and wrote "enumerate both directions". SUMMARY NOT A SENTENCE was in neither — added by that same session's sweep and never written down. Both places fixed. And the last of the 2022 rst conversion: a `{.title-ref}` artefact that a reader sees verbatim where a link should be. linkcheck cannot fail it because it is not a link — the third time that sentence applies in this phase. Repaired to the form the preceding line already uses, target confirmed in the Brighter source. No rule added: it occurs once, nothing here can produce another, and a rule guarding a door nobody can open is one the next reader must understand before ignoring. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 ++- contents/BuildingAnAsyncPipeline.md | 5 +- spec/010-information_architecture/tasks.md | 53 +++++++++++++++++++++- tools/pagelint.py | 26 +++++++++-- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e562348..771a2a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,8 +287,8 @@ canonical `/llms.txt` prints after the page's title, and what a search engine sh as the page's snippet — all from the same string. A reader meets it before they meet the page. -So it must exist, be **at most 200 characters as rendered**, not end in a colon, and -not be identical to another page's. +So it must exist, be **at most 200 characters as rendered**, end in terminal +punctuation, not end in a colon, and not be identical to another page's. **Rendered, not as typed.** `[Configuration](/contents/BrighterBasicConfiguration.md)` is fourteen characters to a reader and fifty-four in the source. Measuring the source @@ -399,6 +399,7 @@ failure the claim in this paragraph is meant to prevent: | Convention | Rule | Repo-wide | `--changed` | |---|---|---|---| | One H1 per file, before the banner | 1's precondition (`NO H1`) | error | error | +| …and no *second* H1 | 1's precondition (`EXTRA H1`) | error | error | | Banner present as the first non-blank line after the H1 | 1 | error | error | | Banner matches `BANNER_RE` — type in vocabulary, *Applies to* present | 2 | error | error | | Heading qualification, across pages (`##`, allowlist exempt) | 3a | error | error | @@ -409,6 +410,7 @@ failure the claim in this paragraph is meant to prevent: | An opening sentence exists | 7 (`SUMMARY MISSING`) | error | error | | It is ≤ 200 characters **rendered** | 7 (`SUMMARY TOO LONG`) | error | error | | It does not end in a colon | 7 (`SUMMARY ENDS IN COLON`) | error | error | +| It ends in terminal punctuation | 7 (`SUMMARY NOT A SENTENCE`) | error | error | | It is unique across pages | 7 (`SUMMARY NOT UNIQUE`) | error | error | | `description:` front matter equals it | 7 (`DESCRIPTION MISMATCH`) | error | error | | That front matter is a quoted single line | 7 (`DESCRIPTION UNREADABLE`) | error | error | diff --git a/contents/BuildingAnAsyncPipeline.md b/contents/BuildingAnAsyncPipeline.md index c688407..1b06980 100644 --- a/contents/BuildingAnAsyncPipeline.md +++ b/contents/BuildingAnAsyncPipeline.md @@ -13,7 +13,7 @@ An async pipeline wraps an async request handler in the same orthogonal operatio This uses Brighter's [command processor](/contents/CommandsCommandDispatcherandProcessor.md#the-command-processor-pattern) features rather than its [command dispatcher](/contents/CommandsCommandDispatcherandProcessor.md#command-dispatcher) features. -# Implementing a Pipeline +## Implementing an Async Pipeline The first step in building a pipeline is to decide that we want an orthogonal operation in our pipeline. Let us assume that we want to do command sourcing. @@ -25,8 +25,7 @@ The limitation here is that you can only make assumptions about the type you rec Although it is possible to implement the [IHandleRequestsAsync](https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter/IHandleRequestsAsync.cs) interface directly, we recommend deriving your handler from -[RequestHandlerAsync\ -\]{.title-ref}\_\_. +[RequestHandlerAsync\](https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter/RequestHandlerAsync.cs). Let us assume that we want to log all requests travelling through the pipeline. (We provide this for you in the Brighter.CommandProcessor packages so this for illustration only). We could implement a generic handler as follows: diff --git a/spec/010-information_architecture/tasks.md b/spec/010-information_architecture/tasks.md index 7f60000..9dfd8f5 100644 --- a/spec/010-information_architecture/tasks.md +++ b/spec/010-information_architecture/tasks.md @@ -3056,7 +3056,7 @@ its place immediately, since a prose-only PR of this size is exactly the run tha indistinguishable from a real one. **No URL moves and no redirect is owed**: every rewrite changes a link's *target*, not any page's path. -- [ ] **Task 11.8:** `BuildingAnAsyncPipeline.md` has two H1s +- [x] **Task 11.8:** `BuildingAnAsyncPipeline.md` has two H1s - Input: the page, lines 1 and 8 - Output: one H1, and the demoted heading unique and qualified - Notes: No rule sees it. `NO H1` fires only on *no* H1 and rule 3b starts at `##`, so the @@ -3066,6 +3066,57 @@ changes a link's *target*, not any page's path. demoting is safe, but a demoted H1 becomes a `##` that must then be unique across pages and qualified by its subject. **Consider whether the rule should exist**: this is one page today, and one page is how the last un-enforced convention started. + - **Done 2026-08-22.** Demoted, qualified, and the rule exists. See below. + +### Task 11.8 as executed — 2026-08-22 + +**The rule should exist, and the argument for it is the demotion itself.** `NO H1` fires on +*none* and rule 3b starts at `##`, so a second H1 was checked by nothing — and what a second +H1 buys its author is **exemption from every rule a section heading obeys**. Demoting this one +proves it: as `# Implementing a Pipeline` it was fine, and as `## Implementing a Pipeline` it +would have **collided with `BuildingAPipeline.md:52`**, which carries that heading already. +The page escaped rule 3a by claiming to be a title. It is now +`## Implementing an Async Pipeline`, unique and qualified; nothing linked the old anchor, +checked before the edit. + +**`EXTRA H1` is an error repo-wide from the same commit**, which the corpus permits: one page +carried the defect and it is fixed here, so the rule lands at zero — the same shape as rule 4, +which became a repo-wide error once Task 7.2 had cleared its debt. `CLAUDE.md`'s ledger gains +the row in the same commit, because a rule in only one of the two places is how the next round +of decay begins. + +> **And enumerating the ledger for that row found the gap it was meant to prevent — for the +> third time, in the third direction.** Spec 011's acceptance pass found `NO H1` in the linter +> and missing from `CLAUDE.md`. Phase 10 found it in `CLAUDE.md` and missing from the linter's +> own docstring, and its write-up says *"enumerate **both** directions"*. **`SUMMARY NOT A +> SENTENCE` was in neither** — added to the code by that very session's sweep, four defects +> deep in a list, and never written down anywhere else. The session that drew the lesson is the +> session that broke it again, in the same file, within the same phase. **A parity check is +> only true of the moment it ran**; what makes it hold is running it whenever a label is added, +> which is now Task 11.4's job and this row's precedent. + +**One more thing on this page that no tool sees, and it is the last of the 2022 conversion.** + +```text +[RequestHandlerAsync\ +\]{.title-ref}\_\_. +``` + +That is reStructuredText that `pandoc` did not finish converting, and a reader sees it +verbatim — braces, backslashes and all — where a link should be. **`linkcheck.py` cannot fail +it because it is not a link**, which is the same sentence as the `.html` finding and the +`(#)` one, for the third time in this phase. Repaired to the ordinary markdown form the +*preceding line* already uses, with the target confirmed against +`Brighter/src/Paramore.Brighter/RequestHandlerAsync.cs`. + +**No rule was added for it**, deliberately. `{.title-ref}` occurs **once** in 142 pages, the +conversion that produced it happened once in 2022, and nothing in this repository can produce +another. A rule guarding a door nobody can open is a rule the next reader has to understand +before they may ignore it. The `.html` links earned one because 23 of them survived; this +earned a fix. + +**Gates after:** linkcheck **144 files, clean**; pagelint **0 errors** / 791 warnings / 142 +pages; `--check-shape` 0; `--check-redirects` 0 at 77 entries. - [ ] **Task 11.9:** `README.md`'s front matter is enforced by nothing - Input: `README.md`; `pagelint.py`'s `load_pages()` diff --git a/tools/pagelint.py b/tools/pagelint.py index 2c36917..9f190f5 100644 --- a/tools/pagelint.py +++ b/tools/pagelint.py @@ -8,6 +8,7 @@ Rules, and what each is for: NO H1 a page with no title for a banner to sit under + EXTRA H1 a second H1: a section heading written a level too high BANNER MISSING no page banner on the first non-blank line after the H1 BANNER MALFORMED a banner is there, but not in the fixed grammar HEADING NOT UNIQUE a `##` text that also appears on another page @@ -19,6 +20,7 @@ SUMMARY MISSING no prose after the banner to summarise the page with SUMMARY TOO LONG an opening sentence over 200 characters, rendered SUMMARY ENDS IN COLON an opening sentence promising a list an index drops + SUMMARY NOT A SENTENCE an opening sentence with no terminal punctuation SUMMARY NOT UNIQUE two pages that introduce themselves identically DESCRIPTION MISMATCH `description:` front matter that is not that sentence DESCRIPTION UNREADABLE front matter this tool will not guess the meaning of @@ -316,6 +318,22 @@ def check_banner(page): return [error(page.rel, 1, 'NO H1', 'page has no H1; every page needs a title before a banner')] + # NO H1's mirror, and it was invisible for the same reason in reverse: that + # rule fires only on *none*, and rule 3b starts at `##`, so a second H1 was + # checked by nothing at all. One page carried one. A second H1 is a section + # heading written a level too high -- it claims to be the page's title, it + # is exempt from the uniqueness and qualification rules every real section + # obeys, and GitBook renders it indistinguishably from the real title. + extra = [lineno for level, _, lineno in page.headings + if level == 1 and lineno != page.h1_line] + findings = [ + error(page.rel, lineno, 'EXTRA H1', + 'a page has exactly one H1, its title. Demote this to `##` and ' + 'qualify it by the page\'s subject -- as a `##` it must also be ' + 'unique across pages, which is the check it has been escaping') + for lineno in extra + ] + banner_line = None for lineno in range(page.h1_line + 1, len(page.lines) + 1): if page.lines[lineno - 1].strip(): @@ -323,20 +341,20 @@ def check_banner(page): break if banner_line is None: - return [error(page.rel, page.h1_line, 'BANNER MISSING', + return findings + [error(page.rel, page.h1_line, 'BANNER MISSING', f'nothing follows the H1; add a banner below it, e.g.\n {BANNER_EXAMPLE}')] text = page.lines[banner_line - 1].rstrip() if not text.startswith('>'): - return [error(page.rel, banner_line, 'BANNER MISSING', + return findings + [error(page.rel, banner_line, 'BANNER MISSING', f'add a banner below the H1, e.g.\n {BANNER_EXAMPLE}')] if not BANNER_RE.match(text): - return [error(page.rel, banner_line, 'BANNER MALFORMED', + return findings + [error(page.rel, banner_line, 'BANNER MALFORMED', 'banner must read `> **** · Applies to ** V10**' '[ · Prerequisites: ]`, where is one of ' f'{", ".join(PAGE_TYPES)} and the separator is " · " (U+00B7).' f'\n e.g. {BANNER_EXAMPLE}')] - return [] + return findings # --------------------------------------------------------------------------