diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index aa6dbba..dae8594 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,9 +26,12 @@ jobs: python-version: '3.12' - run: python3 tools/linkcheck.py - # Repo-wide: banner, heading uniqueness and terminology are errors on every - # page. The using-directive debt and the untagged fences stay warnings here, - # so the existing backlog is visible without blocking unrelated work. + # Repo-wide: banner, heading uniqueness, terminology and language tags are + # errors on every page. Language tags joined that list with spec 011 Task + # 7.2, which tagged the last 34 untagged fences — a rule with no remaining + # debt needs no softer level. The using-directive debt does still have one, + # so it stays a counted warning here and the backlog is visible without + # blocking unrelated work. - run: python3 tools/pagelint.py # On a pull request the code rules become errors, but only for blocks that diff --git a/CLAUDE.md b/CLAUDE.md index d28d703..5cc9401 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,6 +305,7 @@ Sections mirror `SUMMARY.md`. Each line is `- [Title](path): Type — one senten python3 tools/pagelint.py # whole repo python3 tools/pagelint.py contents/Glossary.md # specific pages python3 tools/pagelint.py --changed origin/master # strict on changed blocks +python3 tools/pagelint.py --fix # repair, then report what is left ``` Exit code is 1 when anything is an error, 0 when clean or warnings only, 2 on bad @@ -319,15 +320,18 @@ nothing beyond the typo, which is the point: a rule that makes small corrections expensive stops people making them. **The ledger.** Every convention above maps to a rule, and every rule maps back. A -rule in only one of the two places is how the next round of decay begins: +rule in only one of the two places is how the next round of decay begins — and the +`NO H1` row is there because the acceptance pass found it missing, which is the exact +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 | | 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 | | Heading qualification, within a page (`##`–`####`, allowlist exempt) | 3b | error | error | -| Language tag on every fence | 4 | warning → error once the backfill lands | error | +| Language tag on every fence | 4 | error | error | | "Dispatcher", not "ServiceActivator" or "Service Activator", in prose | 5 | error | error | | `using` directives in C# blocks | 6 | warning, counted | error, unless the block marks its omission `// ...` | | Version markers on code (❌/✅) | — | **review only** | **review only** | @@ -336,6 +340,22 @@ Version markers are the one convention with no rule, and deliberately so: whethe code blocks differ *by version* is a judgement about meaning, and a regex that guessed at it would fire on every before/after pair in the repo. It is checked in review. +**`--fix` repairs two of these and refuses the rest.** It retargets a banner whose +*Applies to* is stale against `APPLIES_TO` — which is what makes a version bump one +edit to that tuple plus one command — and tags an untagged fence ```` ```text ```` +when nothing in the block looks like code. It rewrites only the version segment, so +the page type and any Prerequisites are out of its reach by construction. + +It **never decides a page type.** That is a judgement about what a page is *for*, it +cannot be recovered from the text, and a wrong one is invisible: a page mislabelled +`Reference` reads perfectly and misleads everyone who trusted the label. A banner with +an out-of-vocabulary type gets its version fixed and still fails rule 2, which is the +intended outcome — `--fix` cannot turn a bad page type into a green build. + +Where the answer is not unique it says so and changes nothing: a version naming a +product no single `APPLIES_TO` entry covers, and a fence holding anything code-shaped, +where choosing between `csharp`, `bash`, `json` and `yaml` belongs to whoever wrote it. + Rule 5 matches **both spellings** — `ServiceActivator` and `Service Activator`. The API surface uses the closed form, but prose here uses the open one just as often, and both are the same V9 term. diff --git a/contents/AsyncAPISupport.md b/contents/AsyncAPISupport.md index f869f25..31ce87d 100644 --- a/contents/AsyncAPISupport.md +++ b/contents/AsyncAPISupport.md @@ -20,7 +20,7 @@ Brighter generates AsyncAPI 3.0 documents by inspecting your registered [subscri - .NET 8.0 or later - Two NuGet packages: -``` +```bash dotnet add package Paramore.Brighter.AsyncAPI dotnet add package Paramore.Brighter.AsyncAPI.NJsonSchema ``` diff --git a/contents/AwsScheduler.md b/contents/AwsScheduler.md index 0b67699..3fc701c 100644 --- a/contents/AwsScheduler.md +++ b/contents/AwsScheduler.md @@ -30,7 +30,7 @@ Brighter provides two approaches for scheduling with AWS EventBridge Scheduler: When `UseMessageTopicAsTarget = true` (default), Brighter schedules messages directly to the target SNS topic or SQS queue: -``` +```text Your Code → CommandProcessor.SendAsync(delay, command) ↓ Brighter creates AWS EventBridge Schedule @@ -54,7 +54,7 @@ Your Dispatcher → Handler executes When `UseMessageTopicAsTarget = false` or using request scheduler, Brighter schedules through an intermediate `FireAwsScheduler` message: -``` +```text Your Code → CommandProcessor.SendAsync(delay, command) ↓ Brighter creates AWS EventBridge Schedule diff --git a/contents/AzureScheduler.md b/contents/AzureScheduler.md index 1faf379..396ba8f 100644 --- a/contents/AzureScheduler.md +++ b/contents/AzureScheduler.md @@ -27,7 +27,7 @@ Azure Service Bus Scheduler is recommended when: Brighter uses Azure Service Bus's native `ScheduledEnqueueTimeUtc` property through the `FireAzureScheduler` message approach: -``` +```text Your Code → CommandProcessor.SendAsync(delay, command) ↓ Brighter creates FireAzureScheduler message diff --git a/contents/BrighterSchedulerSupport.md b/contents/BrighterSchedulerSupport.md index 4839ad2..d8d01df 100644 --- a/contents/BrighterSchedulerSupport.md +++ b/contents/BrighterSchedulerSupport.md @@ -114,7 +114,7 @@ Brighter uses two internal message types for scheduling: ### Scheduling Flow -``` +```text Your Application ↓ CommandProcessor.SendAsync(command, delay) @@ -301,7 +301,7 @@ Brighter supports multiple scheduler implementations. Your choice depends on you ### Decision Guide -``` +```text ┌─────────────────────────────────────────┐ │ Are you deploying to AWS? │ └──────────────┬──────────────────────────┘ diff --git a/contents/CQRSWithBrighterAndDarker.md b/contents/CQRSWithBrighterAndDarker.md index 0e54d04..4af7bc1 100644 --- a/contents/CQRSWithBrighterAndDarker.md +++ b/contents/CQRSWithBrighterAndDarker.md @@ -307,7 +307,7 @@ When building a CQRS application, you'll use both Brighter and Darker together i Here's how Brighter and Darker fit together in a typical ASP.NET Core application: -``` +```text ┌─────────────────────────────────────────────────────────────┐ │ Web Application (ASP.NET Core) │ ├──────────────────────────┬──────────────────────────────────┤ @@ -537,7 +537,7 @@ public class GetOrderSummaryQueryHandler : **Scenario:** Commands write to a primary database; queries read from a replica or separate optimized read database -``` +```text ┌────────────────┐ │ Commands │ │ (Brighter) │ @@ -580,7 +580,7 @@ public class GetOrderSummaryQueryHandler : This advanced pattern stores all state changes as a sequence of events. The query side builds read models (projections) by replaying events. -``` +```text ┌────────────────┐ │ Commands │ │ (Brighter) │ diff --git a/contents/CloudEventsSupport.md b/contents/CloudEventsSupport.md index f0d94f7..52ce5d4 100644 --- a/contents/CloudEventsSupport.md +++ b/contents/CloudEventsSupport.md @@ -62,7 +62,7 @@ In binary-mode, CloudEvents attributes are mapped to protocol headers, and the e - You want to inspect event metadata without deserializing the body **Example RabbitMQ message with binary CloudEvents:** -``` +```text Headers: ce_id: "a89b61a2-5c5c-4d7e-8b8f-2e0f9c1d3e4f" ce_source: "https://example.com/orders" diff --git a/contents/HowServiceActivatorWorks.md b/contents/HowServiceActivatorWorks.md index 91b8d76..34519c4 100644 --- a/contents/HowServiceActivatorWorks.md +++ b/contents/HowServiceActivatorWorks.md @@ -25,7 +25,7 @@ The `Message Pump`: ## Dispatcher Architecture -``` +```text External Message Broker ↓ [Dispatcher] (ServiceActivator assembly) diff --git a/contents/InMemoryScheduler.md b/contents/InMemoryScheduler.md index a9c5ac2..80a0e08 100644 --- a/contents/InMemoryScheduler.md +++ b/contents/InMemoryScheduler.md @@ -33,7 +33,7 @@ This simple approach makes it perfect for testing but unsuitable for production ## InMemory Scheduler Architecture -``` +```text Your Code ↓ CommandProcessor.SendAsync(command, delay) diff --git a/contents/PipelineValidation.md b/contents/PipelineValidation.md index d54441f..54084cd 100644 --- a/contents/PipelineValidation.md +++ b/contents/PipelineValidation.md @@ -44,7 +44,7 @@ These checks apply to all Brighter applications, including those that only use t **Example error messages:** -``` +```text Handler type 'MyNamespace.OrderHandler' is not public — Brighter only supports public handler types. Make the class public so the pipeline builder can find it @@ -76,7 +76,7 @@ These checks apply when you configure outgoing messages with `AddProducers()`. **Example error messages:** -``` +```text Publication.RequestType is null — Post()/Deposit() will throw ConfigurationException Publication.RequestType 'MyNamespace.OrderData' does not implement IRequest @@ -94,7 +94,7 @@ These checks apply when you configure incoming messages with `AddConsumers()`. **Example error messages:** -``` +```text Subscription uses Reactor (sync) pump but handler 'OrderHandler' is async — use Proactor for async handlers @@ -113,7 +113,7 @@ The `DescribePipelines()` method logs a structured report showing how your pipel At `Information` log level, a single summary line is logged: -``` +```text Brighter: 3 handler pipelines, 2 publications, 5 subscriptions configured ``` @@ -123,7 +123,7 @@ The summary includes counts only for the configuration paths you use. If you don At `Debug` log level, the report shows the full wiring for each configuration path. Here is an example with all three paths configured: -``` +```text === Handler Pipelines === OrderCreatedHandler (async) Pipeline: [DeferMessageOnErrorAsync(0)] → [UseResiliencePipelineAsync(1)] → OrderCreatedHandler diff --git a/contents/QueriesAndQueryObjects.md b/contents/QueriesAndQueryObjects.md index 771b5af..88d9e09 100644 --- a/contents/QueriesAndQueryObjects.md +++ b/contents/QueriesAndQueryObjects.md @@ -650,7 +650,7 @@ Use descriptive, specific names that clearly communicate the query's purpose. Organize query files in a way that makes them easy to find and maintain: **Option 1: Queries folder** -``` +```text /Queries GetOrderQuery.cs GetCustomerQuery.cs @@ -658,7 +658,7 @@ Organize query files in a way that makes them easy to find and maintain: ``` **Option 2: Feature folders** -``` +```text /Features /Orders GetOrderQuery.cs @@ -669,7 +669,7 @@ Organize query files in a way that makes them easy to find and maintain: ``` **Option 3: Colocation with handlers** -``` +```text /Orders /Queries GetOrderQuery.cs @@ -728,7 +728,7 @@ This approach reduces file count and keeps related code together. For microservices or modular monoliths, consider a shared query library: -``` +```text /MyApp.Contracts /Queries GetOrderQuery.cs diff --git a/contents/QueryPipeline.md b/contents/QueryPipeline.md index c022721..caede1b 100644 --- a/contents/QueryPipeline.md +++ b/contents/QueryPipeline.md @@ -16,7 +16,7 @@ This approach follows the [Decorator Pattern](https://en.wikipedia.org/wiki/Deco When you call `IQueryProcessor.ExecuteAsync(query)`, Darker constructs a pipeline of decorators around your query handler based on the attributes you've applied to the handler's `ExecuteAsync` method. The execution flows through each decorator in order before reaching your handler: -``` +```text QueryProcessor.ExecuteAsync(query) ↓ [QueryLogging Decorator - Step 1] diff --git a/contents/ReplayOnSeen.md b/contents/ReplayOnSeen.md index 7d2e268..2f94b77 100644 --- a/contents/ReplayOnSeen.md +++ b/contents/ReplayOnSeen.md @@ -149,7 +149,7 @@ which are reserved for workflow orchestration. When the Inbox recognises a request it has already handled and the action is `Replay`: -``` +```text Duplicate PlaceOrder arrives │ ▼ @@ -555,7 +555,7 @@ stores for their schema capability rather than assuming it. Each message is prefixed with the handler it came from — `Handler 'ProcessPaymentHandler'`. An unconfigured Inbox renders as `'(none)'` in the first message. The messages read: -``` +```text OnceOnlyAction.Replay requires a causation-tracking inbox, but the configured inbox 'MyCustomInbox' does not implement IAmACausationTrackingInbox — Replay cannot find the causation id of the original handling @@ -611,7 +611,7 @@ pipeline's context at all and falls back to a throwaway whose Bag never reaches This one does leave a trace: a single warning, logged once per process the first time a Replay pipeline hits it. -``` +```text A custom IRequestContext (not a RequestContext) was supplied; the causation id cannot flow to downstream handlers, so OnceOnlyAction.Replay will be a no-op ``` diff --git a/contents/Telemetry.md b/contents/Telemetry.md index 2c75355..ff79460 100644 --- a/contents/Telemetry.md +++ b/contents/Telemetry.md @@ -204,7 +204,7 @@ When Brighter operates as a Dispatcher (message consumer), it creates spans for ### Example Flow -``` +```text Dispatcher Span: "task.commands receive" (Consumer) └─> Message Translation (sibling) └─> Command Processor Span: "ProcessTaskCommand send" (Internal) @@ -231,7 +231,7 @@ Outbox operations create child spans for database operations: ### Deposit Operation -``` +```text deposit span (Internal) └─> Transform pipeline spans └─> Outbox add span (Database) @@ -239,7 +239,7 @@ deposit span (Internal) ### Clear Operation -``` +```text create/clear span (Internal) └─> Outbox get span (Database) └─> Produce message span (Producer) @@ -262,7 +262,7 @@ Outbox and Inbox database operations follow [OTel Database Semantic Conventions] Inbox operations create child spans for deduplication checks: -``` +```text Dispatcher receive span (Consumer) └─> Message translation └─> Inbox check span (Database) @@ -284,7 +284,7 @@ Transform operations (Claim Check, Compression, Encryption) create child spans f ### Claim Check (S3 Example) -``` +```text deposit span (Internal) └─> ClaimCheck transform span └─> S3 put object span (HTTP Client) @@ -293,7 +293,7 @@ deposit span (Internal) ### Retrieve Claim -``` +```text Message translation span └─> RetrieveClaim transform span └─> S3 get object span (HTTP Client) @@ -317,7 +317,7 @@ Brighter automatically propagates trace context across service boundaries using ### Message Headers -``` +```text traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 tracestate: congo=t61rcWkgMzE ``` @@ -326,7 +326,7 @@ tracestate: congo=t61rcWkgMzE Brighter participates in existing traces. When called from an ASP.NET controller, the Command Processor span becomes a child of the ASP.NET request span: -``` +```text ASP.NET Request: "POST /orders" └─> Command Processor: "ProcessOrderCommand send" └─> Handler: OrderHandler @@ -463,7 +463,7 @@ await host.RunAsync(); A complete distributed trace across services: -``` +```text ASP.NET Request (OrderService): "POST /api/orders" └─> Command Processor: "CreateOrderCommand send" └─> Handler: CreateOrderCommandHandler diff --git a/spec/011-authoring_conventions/tasks.md b/spec/011-authoring_conventions/tasks.md index 41ade8f..9ab8d1c 100644 --- a/spec/011-authoring_conventions/tasks.md +++ b/spec/011-authoring_conventions/tasks.md @@ -668,17 +668,17 @@ gone, verified by byte inspection rather than by eye. - Output: `spec/011-authoring_conventions/worklist.md` — Page (path + lines) · Mode score with modes named · Verdict (`split` / `keep` / `keep — outside Diátaxis`) · Proposed shape · Rationale - Notes: **Must stand alone without this spec in context** — Spec 010 executes against it. Seeded from the 31 pages scoring ≥3 modes, minus the two split here. **The score is a triage signal, not a verdict:** `Glossary.md` (589 lines, single mode) and `KafkaConfiguration.md` (606 lines, one mode) are the standing reminders that size and score both mislead — record them as `keep` with the reason, so 010 does not re-open the question. Pages that resisted classification in Task 3.2 go here as split candidates; pages legitimately outside Diátaxis (`FAQ.md`, `Glossary.md`, `V10MigrationGuide.md`) must **not** be recorded as split candidates. -- [ ] **Task 7.2:** P1 — add language tags to the 185 untagged fences, then flip rule 4 to error +- [x] **Task 7.2:** P1 — add language tags to the 185 untagged fences, then flip rule 4 to error - Input: `pagelint.py` rule 4 warnings - Output: Language tags across `contents/`; rule 4 promoted to repo-wide error; `CLAUDE.md` *Enforcement* updated to match - Notes: Mechanical enough to sit alongside the sweeps. Pick the tag from the block's content — `csharp`, `yaml`, `json`, `bash`, `text` for output dumps. Promote the rule in the **same** commit, or the tags decay like everything else unenforced. -- [ ] **Task 7.3:** P1 — add `--fix` to `pagelint.py` +- [x] **Task 7.3:** P1 — add `--fix` to `pagelint.py` - Input: design §1 comparison table, requirements § P1 - Output: `--fix` covering the mechanical rules: banner **version segment**, language tags - Notes: Scope it narrowly — `--fix` must **never** decide a page type; it cannot know one. Its reason for existing is that the V11 bump is 105 edits, and it should be one command plus a diff review rather than a page-by-page trudge. Do **not** fold `apply_banners.py`'s TSV logic in: that would leave a durable tool carrying one-off migration logic, keyed to a file that by then records a decision made years earlier. -- [ ] **Task 7.4:** Acceptance pass and programme handoff +- [x] **Task 7.4:** Acceptance pass and programme handoff - Input: requirements § Acceptance Criteria (AC1–AC8) - Output: A checked-off AC list appended to this file; `PROMPT.md` updated with 011's completion state and the measured baseline; `PROMPT.md` open question 3 closed - Notes: Walk all eight: `pagelint.py` exits 0 with the warning count recorded (AC1) · `linkcheck.py` exits 0 including orphans (AC2) · every banner human-reviewed (AC3) · no cross-page `##` collisions outside the allowlist (AC4) · `CLAUDE.md` ↔ linter parity in **both** directions and the *File Organization Pattern* no longer prescribing rejected headings (AC5) · CI green with both tools, and green on the untouched tree *before* the sweeps (AC6) · splits navigable with redirects settled (AC7) · worklist executable without re-deriving the analysis (AC8). There are eight: requirements numbered two of them "6" until this list's review renumbered them to 1–8, and design's traceability row was updated to match. Then hand to Spec 010 — it needs the conventions and the worklist, both of which now exist. @@ -958,3 +958,241 @@ Phases 1 and 2 are complete. `linkcheck.py` is green in CI on the untouched tree (run 30881245792, `107 files checked`), `pagelint.py` is written, run and reconciled, and it is **deliberately not in `docs.yml`** — Task 5.1 adds it once the sweeps make it pass. The next session starts at Task 3.1. + +--- + +## Task 7.2 as executed (2026-08-05) + +**All 34 untagged fences carry a language, and rule 4 is a repo-wide error in the same +commit.** `pagelint.py` drops from 836 warnings to 802, and the rule that would have let +them come back is closed behind it. + +### The 34 split 1 `bash` / 33 `text`, and that is the finding + +Not one of the 34 was code. The single `bash` block is +`AsyncAPISupport.md:23`, two `dotnet add package` lines. The other 33 are ASCII flow +diagrams (the scheduler family, `CQRSWithBrighterAndDarker.md`, +`HowServiceActivatorWorks.md`), directory-layout trees (`QueriesAndQueryObjects.md` ×4), +validation-message dumps (`PipelineValidation.md` ×5, `ReplayOnSeen.md`) and trace-tree +output (`Telemetry.md` ×9) — all `text`. + +That is not a coincidence and it is the explanation for the debt existing at all. An +author writing C# reaches for ```` ```csharp ```` because they want the highlighting; +an author drawing a box-and-arrow diagram has no language in mind and types a bare +fence, because no tag is the honest answer to "what language is this?" until you know +`text` is the convention. **The untagged population was never latent code debt** — it +was the absence of a name for "not code". + +**Corroboration, not assertion:** the using-directive debt is **802 blocks across 93 +pages before and after**. Had any of the 34 held C#, tagging it `csharp` would have +added it to rule 6's population and moved that number. It did not move. + +### The warning count reconciles exactly + +836 → 802, and 802 is the whole using-directive debt. The split PROMPT.md recorded — +802 rule 6 + 34 rule 4 — held, and the residue after removing rule 4 is rule 6 alone. + +### The gate was proved red before it was trusted green + +Rule 4's flip was verified by breaking it on purpose: retagging `Telemetry.md:207` back +to a bare fence produces `1 errors` and **exit 1**, with the finding printed without the +`(warning)` label. Restored, the tree is back to `0 errors`. This is the discipline from +Task 5.2 — a check that passes has not necessarily checked anything. + +`--changed origin/master` is also green and also non-vacuous: `changed_ranges` returns +**15 files / 38 hunks**. No changed line overlaps a C# block, because every line this +task touched is the opening fence of a `text` or `bash` block. + +### Rule 4's flip is unlike rule 6's, and the difference is the point + +Rule 6 has two strictness levels because it has 802 blocks of standing debt and a +repo-wide error would block every unrelated edit. Rule 4 now has **no debt at all**, so +it needs no softer level: an untagged fence today is a fence added today. The docstring +says so, and the `LANGUAGE_TAG_IS_ERROR` comment records why the flag survives rather +than being inlined — it is the record of a completed backfill, not a switch anyone is +expected to flip back. + +`CLAUDE.md`'s ledger row moved from `warning → error once the backfill lands` to +`error` in both columns, keeping the two-directional parity AC5 checks. + +### What was left alone, deliberately + +The **149 space-separated fences** (```` ``` csharp ````) are still space-separated. +They are tagged, they render as C#, and rule 4 has never flagged them — see § *Measured +baseline*. Normalising them would touch 40-odd pages to change nothing a reader or a +tool can see, and would have buried a 34-line diff whose whole claim is that it contains +nothing but language tags. + +--- + +## Task 7.3 as executed (2026-08-06) + +`--fix` repairs the banner version segment and untagged fences, and refuses everything +else out loud. Both halves were tested against a real target rather than a synthetic +one, and the language-tag half had **ground truth available** — the 34 fences Task 7.2 +had just tagged by hand. + +### The version bump was rehearsed end to end + +`APPLIES_TO` was temporarily moved to `('Brighter V11 and Darker V5', 'Brighter V11', +'Darker V5')` and `--fix` run against the corpus: + +- **110 banners stale, 110 fixed, 0 left for a human.** 100 → `Brighter V11`, + 5 → `Darker V5`, 5 → `Brighter V11 and Darker V5`, which reconciles with the 10 + Darker-touching pages already identified by `pagetypes.tsv`'s `applies` column. +- **Page types survived exactly**: 50 Reference / 33 How-to / 27 Explanation before and + after — the corrected tally, unmoved. +- **All five Prerequisites segments survived.** This is the failure `apply_banners.py` + actually shipped (`5498cd6`), so it was the first thing checked rather than assumed. +- **One line changed per page**: 110 files, every one `1 insertion, 1 deletion`, 109 + hunks at line 3 and one at line 4. No collateral edits. +- **The 17 files with no trailing newline still have none.** Byte-compared against + `HEAD`, not eyeballed. +- **Idempotent**: the second run reports `Nothing to fix.` + +There is no migration map anywhere. A stale value names a set of products +(`Brighter V10` names `{Brighter}`) and the fix is whichever `APPLIES_TO` entry names +exactly that set. **One edit to the tuple is the whole bump.** Where two entries claim +the same product set — a vocabulary *restructure* rather than a bump — it refuses +rather than guessing. + +### `--fix` cannot launder a bad page type into a green build + +Given `> **Guide** · Applies to **Brighter V9**`, it fixes the version to +`Brighter V11`, leaves `**Guide**` alone, and the page **still fails rule 2**. That is +the property the narrow scope exists for. A page type cannot be recovered from the text +and a wrong one is invisible — a page mislabelled `Reference` reads perfectly and +misleads every reader who trusted the label. Only the version segment is ever +substituted, so the type and Prerequisites are out of reach by construction rather than +by care. + +Three refusal paths, each with its own message, all exercised: two entries claiming one +product set; a product absent from the vocabulary; a value that is not ` V` +at all. + +### The language-tag half: 26 of 34 right, 0 wrong, 8 declined + +Replaying `--fix` against the pre-Task-7.2 tree — 34 untagged fences with hand verdicts +already recorded — gives **26 tagged `text` and 8 held back**. Every one of the 26 +matches the verdict reached by hand. **It has never produced a wrong tag; its only +failure mode is doing nothing and saying so.** + +Of the 8 refusals, **one is exactly right**: `AsyncAPISupport.md:23` is `dotnet add +package`, the single block in the whole set that took `bash` rather than `text`, and it +was held for being a shell command. The other 7 are over-caution, 6 of them from the +`key: value` detector firing on labelled output — `traceparent: 00-0af…`, +`Attributes: s3.bucket…`, `Brighter: 3 handler pipelines…`. The seventh is +`PipelineValidation.md:47`, where a validation message wraps onto a line beginning +"public handler types.", which reads as a C# declaration at line start. + +That asymmetry is the design, not a shortfall. The detectors are not a classifier; they +hold the fix back on anything code-shaped, because `text` is the only tag inferable +from a corpus where **all 34 untagged fences were prose**. Choosing between `csharp`, +`bash`, `json` and `yaml` is a decision belonging to whoever wrote the block. A tool +that guessed would be wrong silently; this one is unhelpful loudly. + +### What was deliberately kept out + +- **`apply_banners.py`'s TSV lookup**, as the task specified. That is one-off migration + logic keyed to a file recording decisions taken years before the next person runs + this; folding it in leaves a durable tool quietly carrying it. +- **Rule 6.** `--fix` cannot invent `using` directives, and writing `// ...` on a + reader's behalf would convert a debt into a declaration nobody made. +- **`--fix --changed`** is rejected with exit 2. It reads as "fix what I changed", but + `--changed` only varies the strictness of a rule `--fix` does not repair, so the + combination would quietly do something other than what it says. + +### A stale figure noticed in passing + +*Audit data* records **18 of 105 files with no trailing newline**. It is now **17 of +110** — one gained a newline during the Phase 6 splits. Nothing depends on it, and it +is recorded here rather than fixed, because normalising the other 17 is still the +unrelated-diff problem that left them alone in the first place. + +--- + +## Task 7.4 as executed (2026-08-06) — the acceptance pass + +All eight criteria walked, each verified by running something rather than by recalling +what a previous session reported. **Seven passed as they stood; AC5 failed and was +fixed** — see below. Spec 011 is complete. + +| | Criterion | Verdict | +|---|---|---| +| AC1 | `pagelint.py` exits 0, debt recorded | **pass** — 0 errors, 802 warnings | +| AC2 | `linkcheck.py` exits 0, orphans included | **pass** — 112 files | +| AC3 | Every page banner human-reviewed | **pass** — 110/110, verdicts matched to banners | +| AC4 | No cross-page `##` collisions | **pass** — 0 of 688 distinct headings | +| AC5 | `CLAUDE.md` ↔ linter parity, both ways | **failed, then fixed** — `NO H1` | +| AC6 | CI runs both tools, green before the sweeps | **pass** | +| AC7 | Splits navigable, redirects settled | **pass** — no URL moved | +| AC8 | Worklist executable by 010 | **pass** — 42 rows | + +### AC5 failed: the linter had a rule the ledger did not + +`pagelint.py` can emit **eight** rule labels; the ledger listed **seven**. The missing +one is `NO H1`, returned by `check_banner` when a page has no title to hang a banner +below. It is rule 1's precondition rather than a rule of its own, which is presumably +why it was never written down — but the ledger's own claim is that *every rule maps +back*, and this one did not. + +Nothing was broken by it: every page has an H1, so the rule has never fired. That is +precisely why it survived four sessions of the ledger being read and edited. **A rule +that never fires is invisible to everything except an enumeration**, which is the only +reason this pass caught it. Added as its own row. + +The rest of AC5 holds. Rules 1–6 and the review-only row account for the other seven +labels one-for-one, and the *File Organization Pattern* prescribes qualified headings +(`## Kafka Subscription Configuration`) rather than the bare `## Configuration` it +called for before Phase 1. + +### A second drift, found while checking AC6 + +`docs.yml`'s comment on the repo-wide step still read "the using-directive debt **and +the untagged fences** stay warnings here". Task 7.2 had made language tags an error two +commits earlier and updated `CLAUDE.md` and the tool docstring, but not the workflow +comment. Corrected. Both findings are the same shape — a true statement somewhere else +going stale because the change was made where it was enforced, not everywhere it was +described. + +### What the mechanical checks established beyond the criteria + +Two of these had never been run, and both could have been false without anything +noticing: + +- **Every page's banner type matches its reviewed verdict in `pagetypes.tsv`** — + 110/110, no mismatches. AC3 as written only asks that a reviewed banner exists; this + asks whether the corpus still agrees with the review. It does. +- **Every page's `Applies to` matches the TSV's `applies` column** — 110/110, tallying + 100 `Brighter V10` / 5 `Darker V4` / 5 both. The 10 Darker-touching pages are exactly + the set the column identifies, so the next Darker release is the one-edit bump it was + positioned to be. +- **AC4 re-derived without the linter**: an independent fence-aware pass over all 110 + pages finds **688 distinct non-navigation `##` slugs and 0 on more than one page**. + Confirming rule 3a with the tool that enforces rule 3a would have proved only that + the tool is self-consistent. + +### The AC1 baseline, restated for whoever shrinks it + +**802 C# blocks across 93 pages** carry no `using` directives. The figure at the start +of the programme was 804 across 89; the Phase 6 splits redistributed them across more +pages and dropped two duplicates. It is the last of 011's debts and is deliberately +left standing — Q4's two strictness levels retire it as pages are edited, rather than +in one sweep that would touch 93 pages to change nothing a reader can see. + +### AC7's redirects, stated plainly rather than ticked + +AC7 asks for "redirects in place for the URLs that moved". **No URL moved.** Both +splits kept the original file name for the core page, so the five new pages are new +URLs with nothing to redirect *from*, and `.gitbook.yaml` needs no `redirects:` block +for them. What the splits did break was **anchor-level** links, which GitBook redirects +cannot address at all — they operate on pages, not fragments — so all 28 were repointed +directly in Phase 6. Adding a `redirects:` block remains Spec 010's deliverable, for +the pages 010 moves. + +### Where this leaves Spec 011 + +**Complete — 43 of 43 tasks.** Every convention it set out to establish is true of every +page, a tool proves it, CI fails when it stops being true, and the version bump that +would otherwise re-open 110 pages is one edit plus one command. Spec 010 is unblocked +and has been since Task 7.1. diff --git a/tools/pagelint.py b/tools/pagelint.py index 1d1b7ad..ea413bc 100644 --- a/tools/pagelint.py +++ b/tools/pagelint.py @@ -11,7 +11,7 @@ BANNER MALFORMED a banner is there, but not in the fixed grammar HEADING NOT UNIQUE a `##` text that also appears on another page HEADING REPEATED a heading text repeated within one page (H2-H4) - LANGUAGE TAG a fenced block with no language (warning) + LANGUAGE TAG a fenced block with no language SERVICEACTIVATOR "ServiceActivator" in prose where "Dispatcher" is meant USING DIRECTIVES a C# block with no `using` lines (warning, counted; stays a warning under --changed if marked `// ...`) @@ -35,9 +35,14 @@ slug() is imported from linkcheck.py, so this tool compares exactly what the link checker resolves anchors against. -Two strictness levels. Repo-wide, missing `using` directives are a warning with -a count, so existing debt is visible without blocking unrelated work. Under ---changed they are an error — but only for code blocks that overlap the diff. +Two strictness levels, and only the using-directive rule uses both. A missing +language tag is an error everywhere: the backfill landed with spec 011 Task 7.2, +so there is no debt for the softer level to protect, and an untagged fence today +is a new one. + +Repo-wide, missing `using` directives are a warning with a count, so existing +debt is visible without blocking unrelated work. Under --changed they are an +error — but only for code blocks that overlap the diff. Block granularity, not file: a file-level rule would mean fixing a typo on a 700-line page obliges backfilling every block on it, which penalises exactly the small corrections worth encouraging. @@ -50,10 +55,16 @@ it, moving a block verbatim between pages is indistinguishable from writing a new one, and a page split cannot honour "move text, do not improve it". +--fix repairs the two rules that have exactly one correct answer: a banner whose +version segment is stale against APPLIES_TO, and an untagged fence whose body +shows no evidence of being code. It never decides a page type. See the --fix +section below for why that boundary is where it is. + Usage: python3 tools/pagelint.py # whole repo python3 tools/pagelint.py contents/Glossary.md # specific pages python3 tools/pagelint.py --changed origin/master # strict on changed blocks + python3 tools/pagelint.py --fix # repair, then report what is left Cross-page uniqueness is a property of the corpus, so when given explicit paths the tool still loads every page for context and only reports on the ones asked @@ -116,10 +127,11 @@ BANNER_EXAMPLE = '> **Reference** · Applies to **Brighter V10**' -# Rule 4 is a warning until the 185 untagged fences are backfilled, then an -# error repo-wide. Flip this in the same commit as the backfill — spec 011 -# Task 7.2 — or the tags decay like everything else unenforced. -LANGUAGE_TAG_IS_ERROR = False +# Rule 4 is a repo-wide error as of spec 011 Task 7.2, flipped in the same +# commit that tagged the last 34 untagged fences. It was a warning while the +# backfill was outstanding; there is no backfill left, so an untagged fence is +# now a new one, and a rule left unenforced is a rule that decays. +LANGUAGE_TAG_IS_ERROR = True # Opening fence: up to three spaces of indent, then a run of >=3 backticks or # tildes, then an optional info string. A closing fence repeats the character at @@ -396,6 +408,207 @@ def check_code_blocks(page, strict_ranges): return findings +# -------------------------------------------------------------------------- +# --fix — the two rules with exactly one correct answer +# -------------------------------------------------------------------------- +# +# A version bump is 110 banners. Retyping one segment across 110 pages is the +# kind of trudge that gets abandoned half-done, and a half-bumped corpus is +# worse than an un-bumped one: some pages assert the new version and some the +# old, with nothing to distinguish a page that was considered from one that was +# missed. That is the whole reason this exists. +# +# It is deliberately narrow, and the boundary is not squeamishness. It fixes +# what has exactly one correct answer and refuses everything else out loud. It +# must never decide a page type: that is a judgement about what a page is *for*, +# it cannot be recovered from the text, and a wrong one is invisible — a page +# mislabelled `Reference` reads perfectly and misleads every reader who trusted +# the label. The same reasoning keeps apply_banners.py's TSV lookup out of here. +# That is one-off migration logic keyed to a file recording decisions taken +# years before the next person runs this tool; folding it in would leave a +# durable tool quietly carrying it. + +Change = namedtuple('Change', 'path line before after note') +Refusal = namedtuple('Refusal', 'path line reason') + +# A vocabulary entry names one or more products, each with its own version: +# `Brighter V10`, `Darker V4`, `Brighter V10 and Darker V4`. +VERSIONED_PRODUCT_RE = re.compile(r'^([A-Za-z][\w.]*) V(\d+)$') + +# Only the version segment is ever rewritten. Capturing the text either side of +# it is what keeps the page type and any Prerequisites segment untouched -- +# the substitution cannot reach them. +APPLIES_SEGMENT_RE = re.compile(r'(· Applies to \*\*)([^*]+)(\*\*)') + + +def products_named(applies): + """The set of product names in a vocabulary value, or None if unparseable.""" + names = set() + for part in applies.split(' and '): + match = VERSIONED_PRODUCT_RE.match(part.strip()) + if not match: + return None + names.add(match.group(1)) + return frozenset(names) + + +def version_targets(): + """Product set -> the one APPLIES_TO entry naming exactly those products. + + This is how a stale banner finds its replacement without anyone writing a + migration map: `Brighter V10` names {Brighter}, and after the V11 bump the + single entry naming {Brighter} is `Brighter V11`. One edit to APPLIES_TO is + the whole bump. + + A product set claimed by two entries is dropped rather than guessed at. That + only happens if the vocabulary is restructured rather than bumped, which is + an editorial change and wants a human. + """ + by_products = defaultdict(list) + for entry in APPLIES_TO: + products = products_named(entry) + if products is not None: + by_products[products].append(entry) + return {p: found[0] for p, found in by_products.items() if len(found) == 1} + + +def fix_banner_version(page): + """Retarget a stale `Applies to` segment. Never touches the type.""" + if page.h1_line is None: + return [], [] + banner_line = None + for lineno in range(page.h1_line + 1, len(page.lines) + 1): + if page.lines[lineno - 1].strip(): + banner_line = lineno + break + if banner_line is None: + return [], [] + + text = page.lines[banner_line - 1].rstrip() + # BANNER_SHAPE_RE, not BANNER_RE: the banner being fixed is by definition + # one BANNER_RE rejects. Shape is what identifies it as ours to rewrite. + if not BANNER_SHAPE_RE.match(text): + return [], [] + match = APPLIES_SEGMENT_RE.search(text) + if not match or match.group(2) in APPLIES_TO: + return [], [] + + stale = match.group(2) + products = products_named(stale) + targets = version_targets() + if products is None: + return [], [Refusal(page.rel, banner_line, + f'`Applies to **{stale}**` is not ` V`, so ' + 'there is nothing to map it from')] + if products not in targets: + named = ' and '.join(sorted(products)) + return [], [Refusal(page.rel, banner_line, + f'`Applies to **{stale}**` names {named}, which no single ' + 'entry in APPLIES_TO covers; this is an editorial change, ' + 'not a bump')] + + current = targets[products] + fixed = APPLIES_SEGMENT_RE.sub( + lambda m: m.group(1) + current + m.group(3), text, count=1) + return [Change(page.rel, banner_line, text, fixed, + f'{stale} -> {current}')], [] + + +# Evidence that a block is code, and so not this tool's to tag. Each entry is +# (what it recognises, pattern); the first to match a line holds the fix back. +CODE_EVIDENCE = ( + ('a C# declaration', re.compile( + r'^\s*(using [A-Za-z_]|namespace\s|(public|private|protected|internal)\s' + r'|(var|await|return|throw|new)\s)')), + ('a statement terminator', re.compile(r'[;{}]\s*$')), + ('a brace', re.compile(r'^\s*[{}]')), + ('a shell command', re.compile( + r'^\s*(\$\s|(dotnet|docker|docker-compose|git|npm|yarn|curl|wget|cd|export' + r'|mkdir|rm|cp|sudo|apt|apt-get|brew|kubectl|helm|python3?|pip3?|bash|sh' + r'|make|az|aws)\b)')), + ('a `key: value` mapping', re.compile(r'^\s*[A-Za-z_][\w.-]*:(\s|$)')), +) + + +def infer_language(block): + """`text`, or None when the block looks like code. + + `text` is the only tag this infers, and that is a finding rather than a + limitation. All 34 fences the Task 7.2 backfill had to tag by hand were + prose — ASCII flow diagrams, directory trees, validation-message dumps, + trace output — and 33 of the 34 took `text`. The reason is mechanical: an + author writing C# reaches for ```csharp because they want the highlighting, + while an author drawing a box-and-arrow diagram has no language in mind and + types a bare fence, because until `text` is the convention no tag is the + honest answer to "what language is this?". + + So the detectors below are not a classifier. They exist to hold the fix back + on the minority case, where choosing between `csharp`, `bash`, `json` and + `yaml` is a decision belonging to whoever wrote the block. Being held back is + the safe failure, and it is reported rather than swallowed. + """ + for _, line in block['body']: + for _, pattern in CODE_EVIDENCE: + if pattern.search(line): + return None + return 'text' + + +def describe_evidence(block): + for lineno, line in block['body']: + for what, pattern in CODE_EVIDENCE: + if pattern.search(line): + return f'line {lineno} looks like {what}' + return 'it looks like code' + + +def fix_language_tags(page): + """Tag an untagged fence `text` when nothing in it suggests code.""" + changes, refusals = [], [] + for block in page.blocks: + if block['info']: + continue + line = page.lines[block['start'] - 1] + if infer_language(block) is None: + refusals.append(Refusal( + page.rel, block['start'], + 'untagged fence left alone: ' + describe_evidence(block) + + ', and picking between `csharp`, `bash`, `json` and `yaml` is ' + 'the author\'s call')) + continue + changes.append(Change(page.rel, block['start'], line.rstrip(), + line.rstrip() + 'text', 'tagged `text`')) + return changes, refusals + + +def apply_changes(changes): + """Write the changes, one file at a time, verifying every line first. + + Nothing is written for a file until every line it targets is confirmed to + hold what the fixer read. A stale line number should abort rather than + half-rewrite a page — the same contract as dedupe_within.py. + """ + by_path = defaultdict(list) + for change in changes: + by_path[change.path].append(change) + + for rel, items in sorted(by_path.items()): + path = os.path.join(ROOT, rel) + with open(path, encoding='utf-8') as fh: + raw = fh.read() + lines = raw.splitlines(keepends=True) + for change in items: + actual = lines[change.line - 1] + ending = actual[len(actual.rstrip('\r\n')):] + if actual.rstrip('\r\n') != change.before: + raise RuntimeError( + f'{rel}:{change.line} moved under the fix; nothing written ' + f'for this file') + lines[change.line - 1] = change.after + ending + with open(path, 'w', encoding='utf-8') as fh: + fh.write(''.join(lines)) + + # -------------------------------------------------------------------------- # Rule 5 — terminology # -------------------------------------------------------------------------- @@ -482,6 +695,7 @@ def changed_ranges(merge_base): def main(argv): merge_base = None + fix = False paths = [] args = list(argv) while args: @@ -492,12 +706,22 @@ def main(argv): file=sys.stderr) return 2 merge_base = args.pop(0) + elif arg == '--fix': + fix = True elif arg.startswith('-'): print(f'unknown option: {arg}', file=sys.stderr) return 2 else: paths.append(arg) + # --changed narrows which *blocks* the using-directive rule is strict about. + # --fix repairs neither that rule nor anything scoped to a diff, so the + # combination reads as "fix what I changed" and would do something else. + if fix and merge_base: + print('--fix and --changed do not combine: --changed only varies the ' + 'strictness of a rule --fix does not repair', file=sys.stderr) + return 2 + pages = load_pages() if paths: @@ -525,6 +749,31 @@ def main(argv): file=sys.stderr) return 2 + if fix: + changes, refusals = [], [] + for rel in reported: + for fixer in (fix_banner_version, fix_language_tags): + made, held = fixer(pages[rel]) + changes += made + refusals += held + try: + apply_changes(changes) + except RuntimeError as exc: + print(f'--fix aborted: {exc}', file=sys.stderr) + return 2 + for change in sorted(changes): + print(f'{change.path}:{change.line}: FIXED: {change.note}') + for refusal in sorted(refusals): + print(f'{refusal.path}:{refusal.line}: NOT FIXED: {refusal.reason}') + print(f'\n{len(changes)} fixed, {len(refusals)} left for a human.' + if changes or refusals else '\nNothing to fix.') + # Re-read, so what follows reports the tree as it now stands rather than + # as it was found. A --fix run that printed pre-fix findings would be + # indistinguishable from one that fixed nothing. + if changes: + pages = load_pages() + print() + findings = [] for rel in reported: page = pages[rel]