diff --git a/AUDIT.md b/AUDIT.md
index 02e9dd29..0f22bb0a 100644
--- a/AUDIT.md
+++ b/AUDIT.md
@@ -58,7 +58,7 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the
- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]).
- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each.
- **recurring-violations** - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, so this dimension is high priority and always runs, and each check is grep-able (see below).
-- **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order).
+- **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). Mechanically checked against the declared model in [`spec/readme-sections.json`][readme-sections]: required sections present, declared sections in their relative order, `License` last, the shields each deliverable implies, the license shield in the closing License section, and the tagline and its mirrors. A heading the model does not name is dropped before the order comparison, so a repo-specific section is never a finding.
## 5. Assert the Actions Implement WORKFLOW.md
@@ -161,6 +161,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta
[governance-branching-model]: ./GOVERNANCE.md#branching-model
[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette
[project-types]: ./spec/project-types.json
+[readme-sections]: ./spec/readme-sections.json
[readme-structure]: ./spec/readme-structure.md
[repo-config]: ./repo-config/
[repo-config-settings]: ./repo-config/settings.json
diff --git a/CODESTYLE.md b/CODESTYLE.md
index 348a3cc6..12028061 100644
--- a/CODESTYLE.md
+++ b/CODESTYLE.md
@@ -36,7 +36,7 @@ These apply repo-wide, in every directory:
1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`][markdownlint-cli2] at the repo root is the single source of truth, and the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length) are **intentional**, so do not "fix" them. `MD033` inline HTML stays **enabled**: HTML comments are permitted (markdownlint does not flag them), `details` and `summary` are allowed because a GitHub collapsible has no Markdown equivalent, every other element is flagged, and anything with a native Markdown equivalent uses the Markdown. Fix violations at the source rather than disabling rules.
2. **Spelling**: All spelling must be clean via the CSpell VS Code integration, and words must be correctly spelled in **US English** (the repo-wide convention, per [GOVERNANCE.md][governance]). The shared `cspell.json` sets `"language": "en-US"` so British spellings are flagged, where a bare `"en"` accepts both US and British and silently passes the wrong spelling. Project-specific terms go in the shared `cspell.json` `words` list, the single source of truth the extension, CLI, and CI all read. The `.code-workspace` must **not** carry its own `cspell.words`/`cSpell.words` block, and when externalizing words into `cspell.json`, delete any word list left in the workspace (a leftover one duplicates the list and silently drifts).
3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only**, because these are the files every repo visitor sees, so they must be clean. It is deliberately **not** all `**/*.md`: repos carry many Markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. Broad, live spell-checking across any file (source, Markdown, text) is the **cspell editor extension's** job, so typos still surface to whoever is editing. A repo owner **may** widen their own CI file list, but README + HISTORY are the default. Keep the CI workflow, the `Lint: Spelling` VS Code task, and the GOVERNANCE.md cspell one-liner on the same file list. The list is explicit (not a glob), so a repo that ships no `HISTORY.md` (e.g. one with no changelog) must drop it from all three surfaces and gate on `README.md` alone, since cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md`, which does not choke on technical terms.
-4. **`HISTORY.md` mirrors the README opening**: `HISTORY.md` is the maintainer-curated changelog and opens as the README's twin, carrying the same `#
` (without the README's ToC-omit comment) and the same intro paragraph copied verbatim, then a `## Release History` section. The mirrored opening keeps the project identity consistent for a reader who lands on the changelog directly. The audit checks that the title and intro match the README, with HTML comments stripped.
+4. **`HISTORY.md` mirrors the README opening**: `HISTORY.md` is the maintainer-curated changelog and opens as the README's twin, carrying the same `# ` (without the README's ToC-omit comment) and the same **tagline** copied verbatim, then a `## Release History` section. The tagline is the first line after the README's H1, and it is the whole of the mirror: a README may carry further paragraphs below it, explaining the project to a reader before the fold, and the changelog does not repeat them, because it opens on the identity and then goes straight to the releases. The mirrored opening keeps the project identity consistent for a reader who lands on the changelog directly. The audit checks that the title and the tagline match the README, with HTML comments stripped.
5. **"Markdown" is the format's name**: The format is a proper noun, so prose capitalizes it, meaning a Markdown file, a Markdown link, and the Markdown a surface renders. Lowercase is for the strings a machine reads and for nothing else: a tool or package name (`markdownlint`, `markdownlint-cli2`, `yzhang.markdown-all-in-one`), a settings key (`markdown.extension.toc.levels`), a heading anchor (`#markdown-and-spelling`), an identifier in code, and a file extension. A hyphenated compound in prose is prose, so it capitalizes too (Markdown-only), which is the boundary a mechanical sweep gets wrong, since it reads the hyphen as the mark of an identifier. What this settles is the mix rather than either spelling, because a file carrying both gives the next author no default to follow and a reviewer a finding to raise on whichever one it wrote last. The rule lives here because every repo carries this file, so the convention arrives with it rather than being re-decided per repo.
## .NET
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 886b8bcb..3d9bef33 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -424,7 +424,7 @@ Contributors commit to this repo with signed commits. The SSH-signing setup live
Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing.
-- **Description** matches the README's first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. The README is the source of truth: set the description from it (`gh api -X PATCH repos// -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same README intro line, so one canonical sentence carries to the README, the About panel, and Docker Hub alike.
+- **Description** matches the README's **tagline**, its first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. The README is the source of truth: set the description from it (`gh api -X PATCH repos// -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same tagline, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the README is carried onward rather than corrected there.
- **Topics** are optional, and any that are present match the repo's actual content. Do not invent topics to fill the field.
- **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them, so they are set by hand and cannot be audited through `gh`.
diff --git a/README.md b/README.md
index 97beb776..35450438 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Agent enablement for a fleet of repositories: autonomy and repeatable quality in
## Build and Distribution
-- **Source Code**: [GitHub][projecttemplate-link] for source, issues, discussions, and CI/CD pipelines.
+- **Source Code**: [GitHub][github-link] for source, issues, discussions, and CI/CD pipelines.
- **Versioned Releases**: [GitHub Releases][releases-link] for version-tagged source archives.
### Build Status
@@ -66,6 +66,7 @@ Nothing here is installed as a dependency. The rules are read, the baseline is c
- [For Workflows](#for-workflows)
- [Questions or Issues](#questions-or-issues)
- [Development Environment Setup](#development-environment-setup)
+- [3rd Party Tools](#3rd-party-tools)
- [License](#license)
## What This Repo Is
@@ -253,6 +254,20 @@ Contributors sign every commit. See [docs/ssh-signing.md][ssh-signing] for SSH c
Changes land the same way every fleet change does: a feature branch, a squash merge into `develop`, a Copilot review loop driven to green, and a merge only with the maintainer's explicit approval. The backlog is [`TODO.md`][todo], which holds the work that is ready to pick up along with the reasoning behind each item, so read it before proposing something it already covers.
+## 3rd Party Tools
+
+The third-party tools, libraries, and actions this project depends on.
+
+| Tool | Role |
+| --- | --- |
+| [cspell][cspell-link] | Spell checker. |
+| [editorconfig-checker][editorconfig-checker-link] | Line-ending and whitespace linter. |
+| [GitHub Actions][github-actions-link] | CI and automation runner. |
+| [GitHub Dependabot][dependabot-link] | Dependency update bot. |
+| [Markdown All in One][markdown-all-in-one-link] | Markdown editing extension. |
+| [markdownlint-cli2][markdownlint-link] | Markdown linter. |
+| [Nerdbank.GitVersioning][nbgv-link] | Version computation from git height. |
+
## License
Licensed under the [MIT License][license]\
@@ -273,6 +288,15 @@ Licensed under the [MIT License][license]\
[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate?label=License
[releases-build-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Releases%20Build
+
+
+[actions-link]: https://github.com/ptr727/ProjectTemplate/actions
+[commits-link]: https://github.com/ptr727/ProjectTemplate/commits
+[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions
+[github-link]: https://github.com/ptr727/ProjectTemplate
+[issues-link]: https://github.com/ptr727/ProjectTemplate/issues
+[releases-link]: https://github.com/ptr727/ProjectTemplate/releases
+
[agent-safety]: ./host-setup/agent-safety/README.md
@@ -316,9 +340,10 @@ Licensed under the [MIT License][license]\
-[actions-link]: https://github.com/ptr727/ProjectTemplate/actions
-[commits-link]: https://github.com/ptr727/ProjectTemplate/commits
-[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions
-[issues-link]: https://github.com/ptr727/ProjectTemplate/issues
-[projecttemplate-link]: https://github.com/ptr727/ProjectTemplate
-[releases-link]: https://github.com/ptr727/ProjectTemplate/releases
+[cspell-link]: https://cspell.org
+[dependabot-link]: https://github.com/dependabot
+[editorconfig-checker-link]: https://github.com/editorconfig-checker/editorconfig-checker
+[github-actions-link]: https://github.com/actions
+[markdown-all-in-one-link]: https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one
+[markdownlint-link]: https://github.com/DavidAnson/markdownlint-cli2
+[nbgv-link]: https://github.com/dotnet/Nerdbank.GitVersioning
diff --git a/TODO.md b/TODO.md
index 977005d8..7bf67886 100644
--- a/TODO.md
+++ b/TODO.md
@@ -39,9 +39,9 @@ One pull request pointing a hub `uses:` at a hub-owned action, so that the resol
### The Declared Repository Description
-One pull request moving the canonical short description into declared data, which settles the second-paragraph ambiguity by construction rather than by writing an extraction rule the same change then deletes.
+One pull request moving the canonical short description into declared data, so every check and every push reads a field rather than parsing a document, and the About panel gets something that writes it.
-**State** `decision`. **Touches** [`registry/repos.json`][repos] and its schema, [`spec/audit.py`][audit], [`spec/readme-structure.md`][readme-structure], and [`CODESTYLE.md`][codestyle]. **Cost** one hub edit plus a carried re-vendor of the `CODESTYLE.md` item, and repos adopt the field one at a time.
+**State** `decision`. **Touches** [`registry/repos.json`][repos] and its schema, [`spec/audit.py`][audit], and `repo-config/configure.sh`. **Cost** one hub edit, and repos adopt the field one at a time. The tagline rule this cluster once carried shipped on 2026-08-08.
- **Declare the description in [`registry/repos.json`][repos] instead of deriving it by parsing the README.** Every check and every push then reads a field.
- **Blocked by** - Nothing.
@@ -54,14 +54,14 @@ One pull request moving the canonical short description into declared data, whic
- **Settled** - The field is optional at first so the audit falls back to the README intro while repos adopt it, and it needs a schema entry because `registry/repos.schema.json` sets `additionalProperties: false`.
- **Settled** - The ask on the Docker repos meanwhile is only that the parsing step is not propagated further.
-- **Let the README intro carry more than the tagline, and say which line the mirrors take.** The current pair of rules forbids a README from saying anything further about itself above the fold.
- - **Blocked by** - The entry above, since taking this first means writing an extraction rule the registry change deletes.
- - **Issue** - [#577][issue-577], which carries the three surfaces that change together.
- - **Checked** - `develop` at `b82c1a3` on 2026-08-05, where [`spec/readme-structure.md`][readme-structure] item 1 reads as though the canonical description is the paragraph after the H1 and [`CODESTYLE.md`][codestyle] has `HISTORY.md` copy the same intro paragraph verbatim.
- - **Open** - Nothing.
- - **Settled** - The shape is that the first line after the H1 is the tagline, it alone carries the 100-character link-free rule, it alone mirrors to the GitHub About panel, the Docker Hub short description and the `HISTORY.md` opening, and any further paragraph is free prose no mirror reads.
- - **Settled** - The audit changes with the rule, since it measures the first non-empty line and would otherwise report a legitimate second paragraph.
- - **Settled** - The cap belongs to a mirror rather than to the reader, which is why declaring the field removes the parser that motivates it.
+- **Close the README-to-About hop, which is the only one nothing writes.** The audit reports a drifted About panel, and no tool sets it.
+ - **Blocked by** - The entry above, since the field is what `repo-config/configure.sh` would set the panel from.
+ - **Issue** - [#577][issue-577], whose tagline half shipped on 2026-08-08.
+ - **Checked** - `develop` on 2026-08-08, where `repo-config/configure.sh` sets every other repository setting and carries no `description` handling, and [`catalog/snippets/workflows/publish-docker-readme-task.yml`][workflows] pushes `github.event.repository.description` to Docker Hub.
+ - **Open** - Nothing beyond sequencing.
+ - **Settled** - The chain is README, then the About panel by hand, then Docker Hub by CI, so the unautomated hop is the first one and it is the one that drifts. PhotoCleaner is the worked case, where the About panel still matched the README and only the Docker Hub short description had diverged.
+ - **Settled** - CI keeps reading `repository.description` rather than the README. Pointing it at the README puts a Markdown parser in a publish job, which PhotoCleaner#32 measured at nine guards, every one of which fails the release rather than the tagline.
+ - **Settled** - The tagline rule itself shipped on 2026-08-08 and is no longer owed here. The extraction rule this entry was once blocked on already existed: [`spec/audit.py`][audit] measured the first line for the About and Docker Hub mirrors all along, and narrowing the `HISTORY.md` mirror to match it was one line, so the sequencing that held the rule behind the registry field was stated more strongly than the code warranted.
### Content in the Wrong File
@@ -122,26 +122,53 @@ One pull request measuring the remaining carried surface against the carry-versu
### The README Structure Rework
-One pull request reworking the README spec to the hand-crafted PlexCleaner shape the maintainer wants, and making the result auditable rather than advisory.
+The spec rework and its audit check shipped. What remains is the per-repo conformance the check now reports, and one section the fleet carries that the model does not name.
-**State** `decision`. **Touches** [`spec/readme-structure.md`][readme-structure] and the `readme-structure` dimension in [`spec/audit.py`][audit]. **Cost** one hub edit, and it re-grades every repo's README.
+**State** `backlog`. **Touches** each repo's `README.md` on its next visit, plus [`spec/readme-structure.md`][readme-structure] and [`spec/readme-sections.json`][readme-sections] if `Build Artifacts` is adopted. **Cost** one edit per repo, driven by the finding rather than by a sweep.
-- **Encode the distribution channel by deliverable rather than as one fixed label.** Four divergences are already identified against PlexCleaner and this repo.
- - **Blocked by** - "The Declared Repository Description", since the mirrors settle first.
+- **Work off the conformance backlog the `readme-structure` dimension now reports.** Measured across all 22 cataloged repos on 2026-08-08, against the shipped checks: 73 findings, 71 on sections and 2 on shields, plus the 3 retired-badge findings the entry below carries.
+ - **Blocked by** - Nothing, and no repo is edited by the hub. Each lands on its own next visit.
- **Issue** - None filed.
- - **Checked** - `develop` at `1ed0cc8` on 2026-08-03.
- - **Open** - Nothing.
- - **Settled** - PlexCleaner ships executables and calls the channel Binary Releases, while the spec fixes the label as Versioned Releases for every repo, so the label belongs in a per-channel table.
- - **Settled** - The wanted form puts the license shield at the very bottom, inside a closing License section reading that the project is licensed under the MIT License, followed by the shield, immediately before the link definitions, which is where PlexCleaner carries it and where this repo's README now carries it too on the maintainer's instruction. [`spec/readme-structure.md`][readme-structure] still says shields are not a top-level section and live under Build and Distribution, so the hub README is deliberately ahead of the spec here and the spec text is part of this cluster's edit rather than a separate fix.
- - **Settled** - The Release Notes section closes by pointing at the release history for complete release notes and older versions, which is the wanted form, and PlexCleaner writes that link inline, which the reference-style rule forbids, so the wording is adopted and the reference form kept.
- - **Settled** - Channel bullets and shields vary by deliverable, meaning GitHub binaries, Docker Hub, NuGet and PyPI each carry a different bullet label and shield set, which is what a per-type table has to encode.
+ - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`.
+ - **Settled** - The shape of the work: 17 repos owe `3rd Party Tools`, 10 owe the `Overview` rename, 9 owe a Table of Contents, 7 owe a License section, and 5 public repos owe `Questions or Issues`.
+ - **Settled** - Three order findings are genuine and each is one move: LanguageTags places Installation after Usage, aiopurpleair places Getting Started after Installation, and PlexCleaner places Questions or Issues immediately after the Table of Contents where the order now puts it ninth.
+ - **Settled** - Two placement findings are genuine: KiCadLibrary carries a `## TODO` after `## License`, which the "TODO.md" rule already forbids, and HomeAutomation-Config renders the license shield twice, once outside the License section.
+ - **Settled** - MediaTools carries a `NuGet Pre-Release` shield that renders the same version as its `NuGet Release` shield, and it is dropped on that repo's next visit. The check does not report it, because a shield class is a floor and an extra shield is never a finding.
+ - **Settled** - Blog is the only repo carrying a `3rd Party Tools` table today, and it needs both fixes the rule now states: drop the License column, and rewrite two of its three roles, since "theme, vendored under `themes/`" and "web server, serving the built site and the redirects" describe this repo's wiring where "static site generator" correctly describes the tool.
+
+- **Bring each repo's `3rd Party Tools` entries onto the shared catalog.** Measured on 2026-08-08: 56 findings across four repos, every one of them a link, a description, or an ordering that disagrees with [`spec/third-party-tools.json`][third-party-tools].
+ - **Blocked by** - Nothing.
+ - **Issue** - None filed.
+ - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`, which now conforms.
+ - **Settled** - The bulk is absent descriptions rather than wrong ones: 48 of the 56 are a tool listed with no description at all, across LanguageTags, MediaTools and PlexCleaner, and PlexCleaner alone accounts for 27. Of the remaining eight, three describe a tool differently from the catalog, four link it differently, and one is Blog listing Hugo, PaperMod, Caddy out of alphabetical order.
+ - **Settled** - Twelve tools already appear in more than one repo, which is what makes the catalog worth having before the 17 repos owing the section write their own wording for each.
+ - **Settled** - Four tools are already linked by two different URLs across the fleet, and the catalog picks one each: GitHub Actions takes `github.com/actions`, Dependabot takes `github.com/dependabot`, Nerdbank.GitVersioning takes the project repo rather than its marketplace action, and uv takes `docs.astral.sh/uv/` to match ruff. The hub was the outlier on the first two and is fixed.
+ - **Settled** - PlexCleaner lists Bring Your Own Badge as a tool, so the retired badge service has a fourth touchpoint beyond the three rendering it, and that entry goes with the same deletion.
+ - **Settled** - The catalog is a standard set and not a complete one, so a tool only one repo uses is unaudited. Of the 36 tools the fleet lists today, 24 are used by exactly one repo and are declared only so the second adopter copies rather than invents.
+
+- **Work off the reference-link naming and grouping backlog.** Measured across all 22 repos on 2026-08-08: 55 letter findings on naming and 27 drift findings on grouping.
+ - **Blocked by** - Nothing, and each repo's block is one edit.
+ - **Issue** - None filed.
+ - **Checked** - Every repo's default branch on 2026-08-08, with the hub read at its own `develop`, which now conforms.
+ - **Settled** - The naming half was already the fleet's practice before it was written down: 119 of 122 shield references end `-shield` and 514 of 532 URI references end `-link`, and `actions-link`, `releases-link`, `issues-link` and `discussions-link` are unanimous across every repo carrying them.
+ - **Settled** - The two real naming inconsistencies are the repository root, which 10 of 20 call `github-link` and the rest name for the project, and `./LICENSE`, which 9 repos call `license-link` where a repo-local path is a bare reference.
+ - **Settled** - The grouping half is drift rather than letter because it is not met: the fleet carries seventeen distinct group-header names, and two repos, NxWitness with 116 definitions and ESPHome-NonRoot with 45, carry no group headers at all.
+ - **Settled** - KiCadLibrary is the largest single block at 22 naming findings, almost all of them repo-local paths named `-link`.
+
+- **Delete the retired `byob.yarr.is` last-build badge from the three repos still carrying it.** The service is deprecated and the badge is not required by any shield class, so the fix is a deletion rather than a replacement.
+ - **Blocked by** - Nothing, and each repo's fix is deleting one shield line and one reference definition.
+ - **Issue** - None filed.
+ - **Checked** - Each repo's default branch on 2026-08-08, with the endpoints requested the same day: MediaTools and KiCadLibrary both return **HTTP 404**, so they already render a broken badge, and ESPHome-NonRoot still returns 200.
+ - **Settled** - The audit reports it, so this does not rely on anyone remembering: `deprecatedShields` in [`spec/readme-sections.json`][readme-sections] carries the retired service and the check fires on exactly those three repos.
+ - **Settled** - A dead badge is worse than an absent one, because it renders broken rather than missing and a visitor cannot tell a retired service from a failing build.
+ - **Settled** - All three repos are already non-conformant on other grounds, so this rides their next visit rather than earning a pass of its own.
-- **Decide whether the canonical section order follows PlexCleaner.** This affects every repo plus the audit dimension.
+- **Decide where `## Build Artifacts` belongs.** LanguageTags and aiopurpleair both carry it, opening with the same `**Build process and artifacts**:` line and covering package, versioning, and publishing.
- **Blocked by** - Nothing.
- **Issue** - None filed.
- - **Checked** - `develop` at `1ed0cc8` on 2026-08-03.
- - **Open** - The position of the sections the spec already names, since PlexCleaner places Questions or Issues immediately after the Table of Contents where the spec orders it ninth.
- - **Settled** - PlexCleaner's Performance Considerations, Runtime Metrics, Custom Plugins, Testing, Development Tooling, Feature Ideas and Sample Media Files are correctly repo-specific under the recurrence rule in [`spec/section-model.md`][section-model] and stay undeclared.
+ - **Checked** - Both repos' default branches on 2026-08-08, where the section is the only one recurring across repos that [`spec/readme-sections.json`][readme-sections] does not name.
+ - **Open** - Whether it becomes a named optional section, folds into `Build and Distribution`, or moves to [`WORKFLOW.md`][workflow], since its content overlaps both.
+ - **Settled** - It is not a finding today. An unnamed heading is dropped before the order comparison, so the two repos carrying it pass, which is why this is a decision rather than a defect.
### Two Project Types and a Shared C++ Style
@@ -474,6 +501,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
[operations]: ./OPERATIONS.md
[project-types]: ./spec/project-types.json
[prose-gate]: ./.github/actions/prose-gate/action.yml
+[readme-sections]: ./spec/readme-sections.json
[readme-structure]: ./spec/readme-structure.md
[reports]: ./reports/
[repos]: ./registry/repos.json
@@ -483,6 +511,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i
[section-model]: ./spec/section-model.md
[snippets]: ./catalog/snippets/
[standup]: ./STANDUP.md
+[third-party-tools]: ./spec/third-party-tools.json
[type-model]: ./spec/type-model.md
[workflow]: ./WORKFLOW.md
[workflows]: ./catalog/snippets/workflows/
diff --git a/spec/audit.py b/spec/audit.py
index 172bd368..a1d46520 100644
--- a/spec/audit.py
+++ b/spec/audit.py
@@ -350,6 +350,438 @@ def title_and_intro(text):
return title, "\n".join(region)
+def tagline(intro):
+ """The first line of a README/HISTORY intro region - the one canonical short description.
+
+ Per spec/readme-structure.md item 1, only this line carries the length and link-free rules and only this
+ line mirrors to the About panel, the Docker Hub short description, and the HISTORY.md opening. Any further
+ paragraph is free prose no mirror reads, so comparing the whole region would report a legitimate second
+ paragraph as a mirror difference.
+ """
+ return intro.split("\n")[0]
+
+
+_MD_IMAGE_REF = re.compile(r"!\[[^\]]*\]\[([^\]]+)\]")
+_MD_IMAGE_INLINE = re.compile(r"!\[[^\]]*\]\((\S+?)\)")
+_LINK_DEF = re.compile(r"^\[([^\]]+)\]:\s*(\S+)", re.M)
+# A URI scheme, requiring two or more characters so a `C:` drive letter is not read as one.
+# Every scheme a README actually carries (mailto, ftp, ssh, git, tel, data) is longer than that.
+_URI_SCHEME = re.compile(r"[a-z][a-z0-9+.\-]+:", re.I)
+
+
+def unfenced_text(text):
+ """`text` with every fenced block removed, EOL-normalized.
+
+ Markup shown inside a code sample is being displayed rather than used, so a `[ref]: url`, a
+ `` or an `![alt][ref]` in one is not a definition, a group, or a rendered badge. Kept as
+ one helper because the checkers were fence-aware in some places and blind in others, which is the state
+ that lets a document be read two ways by one audit.
+ """
+ out, fenced = [], False
+ for ln in normalize(text).split("\n"):
+ s = ln.strip()
+ if s.startswith("```") or s.startswith("~~~"):
+ fenced = not fenced
+ continue
+ if not fenced:
+ out.append(ln)
+ return "\n".join(out)
+
+
+def readme_region(text, heading):
+ """extract_section against a comment-stripped copy of the document.
+
+ extract_section matches a heading by its exact parsed text, so `## License ` is not
+ the section named License and the lookup returns None. Four repos suffix every heading that way for the
+ Markdown All in One extension, and reading them as sectionless reported twelve absent sub-sections that
+ were present. Stripping first is confined to the README checks: extract_section itself must keep the
+ comment, since the verbatim engine hashes a section's exact bytes.
+
+ Fences are stripped here rather than at each caller. Every region the README checks read comes through
+ this one function, so doing it at the root is what stops a code sample inside a section from being read
+ as a badge, a tool row, or a definition by whichever caller forgot.
+ """
+ return extract_section(_HTML_COMMENT.sub("", unfenced_text(text)), heading)
+
+
+def shield_endpoints(region, defs):
+ """The image URLs a Markdown region renders, from both its `![alt][ref]` and its `` uses.
+
+ Keyed on the endpoint rather than the alt text or the reference name, because those are captions. The
+ fleet writes Release Status, Releases Build, Build Status, Workflow Status and Lint Build for one badge,
+ and names its reference `last-commit-shield` and `lastcommit-shield` in the same breath, while the
+ endpoint under img.shields.io is identical in every repo.
+
+ Inline uses are resolved as well as reference ones. Reading references alone made an inline shield
+ invisible rather than wrong, so a repo writing every badge inline, which the reference-link rule forbids
+ for a separate reason, would have passed the shield check by carrying nothing the check could see.
+ A `[ref]: url` definition line is not an image, so it is never counted as a use, which is what keeps a
+ shield's own definition from reading as a second placement of it.
+ """
+ region = region or ""
+ urls = [defs[m.group(1)] for m in _MD_IMAGE_REF.finditer(region) if m.group(1) in defs]
+ return urls + [m.group(1) for m in _MD_IMAGE_INLINE.finditer(region)]
+
+
+def shield_matches(url, shield):
+ """True where a rendered URL is the shield the model describes, by endpoint plus its query discriminators."""
+ return (shield["match"] in url
+ and (shield.get("requireQuery") is None or shield["requireQuery"] in url)
+ and (shield.get("forbidQuery") is None or shield["forbidQuery"] not in url))
+
+
+def addressed_region(text, address):
+ """The region a shield's `in` address names, as `Heading` or `Heading > Sub-heading`, or None if absent."""
+ parts = [p.strip() for p in address.split(">")]
+ body = readme_region(text, parts[0])
+ if body is None or len(parts) == 1:
+ return body
+ want, out, capturing = parts[1].lower(), [], False
+ for ln in body.split("\n"):
+ s = ln.strip()
+ if s.startswith("### "):
+ if capturing:
+ break
+ capturing = s[4:].strip().lower() == want
+ continue
+ if capturing:
+ out.append(ln)
+ return "\n".join(out) if capturing else None
+
+
+def ordered_headings(markdown, level):
+ """Heading texts at `level` in document order, HTML comments stripped, fenced blocks skipped.
+
+ Distinct from heading_texts, which returns an unordered set across every level: the order check needs the
+ sequence, and the ToC-omit comment (`## License `) must not read as a different name.
+ A `## ` line inside a fenced block is not a heading, matching extract_section, so a code sample cannot
+ inject a phantom section.
+ """
+ marker = "#" * level + " "
+ out = []
+ for ln in _HTML_COMMENT.sub("", unfenced_text(markdown)).split("\n"):
+ s = ln.strip()
+ if s.startswith(marker):
+ out.append(s[len(marker):].strip())
+ return out
+
+
+def readme_section_findings(text, model, sel, public):
+ """README section presence and order against spec/readme-sections.json.
+
+ Headings the model does not name are dropped before the order comparison, so the roughly sixty genuinely
+ repo-specific sections across the fleet sit anywhere without a finding - the model constrains the sections
+ it declares and nothing else. A retired name still resolves to its canonical ordinal, so a repo that has
+ not renamed yet is checked on order too rather than silently losing the check along with the name.
+ """
+ findings = []
+ by_name = {s["name"].lower(): s for s in model["sections"]}
+ retired = {r.lower(): s for s in model["sections"] for r in s.get("retiredNames", [])}
+ heads = ordered_headings(text, 2)
+ lower = [h.lower() for h in heads]
+ present = set(lower)
+
+ for h, hl in zip(heads, lower):
+ if hl in retired:
+ findings.append(("LETTER", f"readme: section '{h}' uses a retired name - rename it to '{retired[hl]['name']}', which has no accepted aliases (spec/readme-structure.md)"))
+
+ for s in model["sections"]:
+ req = s["required"]
+ if req == "optional" or (req == "public" and not public):
+ continue
+ if {t.lower() for t in s.get("notApplicableTo", [])} & {t.lower() for t in sel}:
+ continue
+ if s["name"].lower() in present or any(r.lower() in present for r in s.get("retiredNames", [])):
+ continue
+ why = " (a public repo; optional while private)" if req == "public" else ""
+ findings.append(("LETTER", f"readme: no `## {s['name']}` section{why} - it is a required section (spec/readme-structure.md)"))
+
+ seq = [(by_name.get(hl) or retired.get(hl), h) for h, hl in zip(heads, lower) if hl in by_name or hl in retired]
+ for (prev, prev_h), (cur, cur_h) in zip(seq, seq[1:]):
+ if cur["ordinal"] < prev["ordinal"]:
+ findings.append(("LETTER", f"readme: section '{cur_h}' follows '{prev_h}' but is ordered before it - the declared sections keep their relative order (spec/readme-structure.md)"))
+
+ last = next((s for s in model["sections"] if s.get("last")), None)
+ if last and last["name"].lower() in present and lower[-1] != last["name"].lower():
+ findings.append(("LETTER", f"readme: `## {last['name']}` is not the last section, '{heads[-1]}' follows it - it closes the file, immediately before the link definitions (spec/readme-structure.md)"))
+
+ for s in model["sections"]:
+ if s["name"].lower() not in present or not s.get("subsections"):
+ continue
+ have = {h.lower() for h in ordered_headings(readme_region(text, s["name"]) or "", 3)}
+ for sub in s["subsections"]:
+ if sub.lower() not in have:
+ findings.append(("LETTER", f"readme: `## {s['name']}` carries no `### {sub}` sub-section (spec/readme-structure.md)"))
+ return findings
+
+
+def distribution_prefixes(model, slug):
+ """The URL prefixes that make a link this repo's own, from the model's single declaration of ownership."""
+ owner = slug.split("/")[0]
+ return tuple(p.replace("{slug}", slug).replace("{owner}", owner).lower()
+ for p in model.get("distribution", {}).get("urlPrefixes", []))
+
+
+def link_kind(url, slug, is_rendered=False, prefixes=()):
+ """Which linkGroups kind a reference definition points at, for the repo at `slug`.
+
+ `is_rendered` says whether this one reference is used as an image, which makes it a shield whatever host
+ serves it. It is per reference rather than per URL deliberately: two names can point at one URL, and
+ keying on the URL would make a plain link a shield because some other reference to the same address is
+ rendered, which the spec's wording ("a badge, judged by the document rendering the reference as an image")
+ does not say.
+
+ Keying on the host instead read a badge from any other host as a plain URI and asked for it to be renamed
+ `-link`, which is a rename away from the convention. The case that found it was a retired last-build
+ service, which deprecatedShields now reports separately, but the rule is about how a reference is used
+ rather than about any one host, so there is no img.shields.io short-circuit here: across the fleet all 119
+ img.shields.io definitions are rendered as images, so a host test classifies nothing usage does not and
+ can only contradict the rule it sits beside.
+
+ A reference is local only when it carries no URI scheme, meaning a path inside this repo. A `mailto:`,
+ `ssh:` or `ftp:` target is a link rather than a file, and reading it as local would demand a bare name and
+ the Repo group for it. No fleet README carries one today, so this is a shape the rule has to get right
+ before one does rather than a defect being repaired.
+
+ Distribution is scoped to this repo's own URLs, so a link to somebody else's GitHub repo or Docker Hub
+ image, which a 3rd Party Tools list is full of, stays external rather than being read as a channel of this
+ project's. Those prefixes come from the model rather than from a second list here, since ownership stated
+ in two places is two things that can disagree, and a canonicalLinks entry is consulted only for a
+ reference this already classified distribution.
+ """
+ if url.startswith("#"):
+ return "anchor"
+ if not url.startswith(("http://", "https://")) and not _URI_SCHEME.match(url):
+ return "local"
+ if prefixes and url.lower().startswith(tuple(prefixes)):
+ return "distribution"
+ return "shield" if is_rendered else "external"
+
+
+def canonical_link_entry(url, model, slug):
+ """The canonicalLinks entry `url` is a destination for, or None where the model fixes no name for it.
+
+ A destination every repo has is called the same thing in every repo, so a reader moving between them is
+ not re-learning names.
+ """
+ base = f"https://github.com/{slug}"
+ bare = url.rstrip("/")
+ for c in model.get("canonicalLinks", []):
+ if "match" in c:
+ if re.search(c["match"], url):
+ return c
+ continue
+ if "repoPath" not in c:
+ # Loud rather than skipped, since skipping would quietly stop enforcing this destination's name.
+ # The schema requires one of the two and validate.py gates it, so reaching here means a model nothing checked.
+ raise KeyError(f"spec/readme-sections.json: canonicalLinks entry '{c.get('name')}' carries neither repoPath nor match")
+ want = (base + c["repoPath"]).rstrip("/")
+ if (bare.lower().startswith(want.lower()) if c.get("prefix") else bare.lower() == want.lower()):
+ return c
+ return None
+
+
+def canonical_name_findings(defs, model, slug, rendered=(), prefixes=()):
+ """Reference names against canonicalLinks, resolved over the whole definition set rather than one at a time.
+
+ A perTarget destination is one a repo may publish several of, and its rule is a count: the bare canonical
+ name where there is exactly one, and `-` where there are several. Judging a definition alone
+ cannot see which case it is in, which is why this is a second pass over the collected set. The qualifier is
+ a prefix on the same name rather than a different name, so one shape covers PlexCleaner's single image and
+ NxWitness's twelve.
+
+ Only a distribution-kind link is renamed, so an upstream image a repo happens to link is left alone.
+ ESPHome-Config links `hub.docker.com/r/esphome/esphome`, which is upstream's image and not a channel of
+ its own, and matching the host alone told it to call that `docker-hub-link`.
+ """
+ findings, hits = [], {}
+ for ref, url in defs:
+ if link_kind(url, slug, ref in rendered, prefixes) != "distribution":
+ continue
+ c = canonical_link_entry(url, model, slug)
+ if c:
+ hits.setdefault(c["name"], (c, []))[1].append((ref, url))
+ for name, (c, group) in hits.items():
+ if c.get("perTarget") and len(group) > 1:
+ for ref, url in group:
+ if not (ref.endswith(f"-{name}") and len(ref) > len(name) + 1):
+ findings.append(("LETTER", f"readme: the reference `[{ref}]` points at {url} - this repo publishes {len(group)} of these, so each is named `-{name}` (spec/readme-structure.md)"))
+ else:
+ for ref, url in group:
+ if ref != name:
+ findings.append(("LETTER", f"readme: the reference `[{ref}]` points at {url} and is named `{name}` in every repo - a shared destination carries a shared name (spec/readme-structure.md)"))
+ return findings
+
+
+def readme_link_findings(text, model, slug):
+ """Reference-definition naming and grouping, per the linkGroups, linkNaming and canonicalLinks model.
+
+ The naming half is a LETTER because the fleet already meets it: 119 of 122 shield references end
+ `-shield` and 514 of 532 URI references end `-link`, so the rule is written down rather than imposed.
+ The grouping half is a DRIFT because it is not met: the fleet carries seventeen distinct group-header
+ names across twenty-two repos, and gating that would bury the naming findings under a re-grouping sweep
+ of every README at once.
+ """
+ findings = []
+ suffixes = {n["kind"]: n["suffix"] for n in model["linkNaming"]}
+ prefixes = distribution_prefixes(model, slug)
+ groups = model["linkGroups"]
+ declared = [g["name"] for g in groups]
+ holds = {g["name"].lower(): g["holds"] for g in groups}
+
+ # Walk the definitions in order, tracking which group header each one falls under.
+ # A comment counts as a group header only where a definition actually falls under it, since the closed set governs the reference-definition block and not every comment in the file.
+ # A tool directive is skipped outright rather than relying on that, because one placed inside the reference block does have definitions under it and would be reported as an unknown group.
+ # The prefixes are declared in the model rather than guessed from the shape of the text, since which tools a repo instructs is a fact about the fleet rather than something to infer.
+ unfenced = unfenced_text(text)
+ seen_headers, current, in_group = [], None, {}
+ for ln in unfenced.split("\n"):
+ s = ln.strip()
+ h = re.fullmatch(r"", s)
+ if h and "omit from toc" not in h.group(1):
+ if not any(h.group(1).lower().startswith(d.lower()) for d in model.get("directiveComments", [])):
+ current = h.group(1)
+ continue
+ d = _LINK_DEF.match(s)
+ if d:
+ if current is not None and current not in in_group:
+ seen_headers.append(current)
+ in_group.setdefault(current, []).append((d.group(1), d.group(2)))
+
+ all_defs = [p for v in in_group.values() for p in v]
+ # A reference the document renders as an image is a shield whatever host serves it, so resolve those first.
+ # Read over the unfenced text for the same reason the definitions are.
+ # An `![alt][ref]` inside a code sample is markup being shown rather than a badge being rendered, and counting it would make a plain link a shield.
+ by_ref = dict(all_defs)
+ rendered = {m.group(1) for m in _MD_IMAGE_REF.finditer(unfenced) if m.group(1) in by_ref}
+ described = {"shield": "a badge", "anchor": "an in-page anchor", "local": "a path in this repo"}
+ for ref, url in all_defs:
+ kind = link_kind(url, slug, ref in rendered, prefixes)
+ want = suffixes.get(kind, "")
+ got = "-shield" if ref.endswith("-shield") else "-link" if ref.endswith("-link") else ""
+ if got != want:
+ shown = f"`{want}`" if want else "a bare name with no suffix"
+ findings.append(("LETTER", f"readme: the reference `[{ref}]` points at {described.get(kind, 'a URI')} and should end in {shown} (spec/readme-structure.md)"))
+ findings += canonical_name_findings(all_defs, model, slug, rendered, prefixes)
+
+ unknown = [h for h in seen_headers if h not in declared]
+ if unknown:
+ findings.append(("DRIFT", f"readme: link-group header(s) outside the declared set: {', '.join(sorted(set(unknown)))} - the groups are {', '.join(declared)} (spec/readme-structure.md)"))
+ ordered = [h for h in seen_headers if h in declared]
+ if ordered != sorted(ordered, key=declared.index):
+ findings.append(("DRIFT", f"readme: the link groups run {', '.join(ordered)} rather than the declared order (spec/readme-structure.md)"))
+ if in_group.get(None):
+ # Distinguish no grouping at all from a stray definition above the first header.
+ # Two repos carry the whole block ungrouped, and "116 definitions sit above the first header" describes that badly.
+ if not seen_headers:
+ findings.append(("DRIFT", f"readme: the {len(in_group[None])} reference definitions carry no group headers - they are grouped under {', '.join(declared)} (spec/readme-structure.md)"))
+ else:
+ findings.append(("DRIFT", f"readme: {len(in_group[None])} reference definition(s) sit above the first group header (spec/readme-structure.md)"))
+ for header, defs in in_group.items():
+ if header is None or not defs:
+ continue
+ names = [r for r, _ in defs]
+ if names != sorted(names):
+ findings.append(("DRIFT", f"readme: the `{header}` group is not sorted by reference name (spec/readme-structure.md)"))
+ want_kind = holds.get(header.lower())
+ strays = {link_kind(u, slug, r in rendered, prefixes) for r, u in defs} - {want_kind} if want_kind else set()
+ if strays:
+ findings.append(("DRIFT", f"readme: the `{header}` group holds {', '.join(sorted(strays))} reference(s) where it holds {want_kind} (spec/readme-structure.md)"))
+ return findings
+
+
+_TOOL_ROW = re.compile(r"^\|\s*\[([^\]]+)\]\[([^\]]+)\]\s*\|\s*([^|]*?)\s*\|")
+_TOOL_BULLET = re.compile(r"^[-*]\s*\[([^\]]+)\]\[([^\]]+)\]\s*(.*)$")
+
+
+def third_party_tool_findings(text, catalog):
+ """A repo's 3rd Party Tools entries against the shared catalog in spec/third-party-tools.json.
+
+ The catalog is a standard set rather than a complete one. A repo's tools are mostly its own, so a tool the
+ catalog does not name produces nothing at all: what is checked is the intersection, meaning that a repo
+ using a tool the fleet has standardized links it by the same URL and describes it the same way. Twelve
+ tools already appear in more than one repo, and three of them are linked by two different URLs today, which
+ is the divergence this closes.
+
+ Matching is by the tool's display name, the text a README links, since that is what a reader compares
+ across repos and what the catalog is keyed on.
+ """
+ body = readme_region(text, "3rd Party Tools")
+ if body is None:
+ return [] # the absent section is already one finding from readme_section_findings
+ declared = {t["name"].lower(): t for t in catalog["tools"]}
+ # Read over the unfenced text, as readme_link_findings does: a sample footnote is not a definition.
+ defs = {m.group(1): m.group(2) for m in _LINK_DEF.finditer(unfenced_text(text))}
+ findings = []
+ listed = []
+ for ln in body.split("\n"):
+ m = _TOOL_ROW.match(ln.strip()) or _TOOL_BULLET.match(ln.strip())
+ if not m:
+ continue
+ name, ref, desc = m.group(1), m.group(2), m.group(3).strip(" -:")
+ listed.append(name)
+ want = declared.get(name.lower())
+ if want is None:
+ continue
+ url = defs.get(ref)
+ if url is not None and url.rstrip("/") != want["link"].rstrip("/"):
+ findings.append(("LETTER", f"readme: 3rd Party Tools links {name} as {url} where the fleet links it as {want['link']} - a shared tool carries one link (spec/third-party-tools.json)"))
+ if desc != want["description"]:
+ shown = f"'{desc}'" if desc else "no description"
+ findings.append(("LETTER", f"readme: 3rd Party Tools describes {name} as {shown} where the fleet describes it as '{want['description']}' - a shared tool carries one description (spec/third-party-tools.json)"))
+ # Ordering covers every tool listed, not only the cataloged ones, since a reader scans the whole list.
+ if listed != sorted(listed, key=str.lower):
+ first = next(f"{a} before {b}" for a, b in zip(listed, listed[1:]) if a.lower() > b.lower())
+ findings.append(("LETTER", f"readme: 3rd Party Tools is not alphabetized ({first}) - the list is scanned rather than read (spec/readme-structure.md)"))
+ return findings
+
+
+def readme_shield_findings(text, model, entry):
+ """Shield presence, by the additive classes in spec/readme-sections.json.
+
+ Each shield names the section it belongs in, so the license shield is an ordinary member of the base class
+ that happens to sit in the closing License section rather than a second model beside this one. Where a
+ named section is absent, the shield is skipped, because readme_section_findings already reports the
+ section and a second finding would describe the same gap.
+
+ A class is a floor, so an extra shield is never a finding. The classes deliberately assert the endpoint
+ and not the channel: a repo publishing several images carries a version shield per image per channel
+ (NxWitness carries forty, across six images and four channels that are not `develop`), so a
+ latest-plus-develop pair is the single-image form the spec recommends rather than something every docker
+ repo can be measured against.
+ """
+ findings = []
+ defs = {m.group(1): m.group(2) for m in _LINK_DEF.finditer(unfenced_text(text))}
+ # A retired badge service is scanned across the whole document rather than per section, since a dead badge is wrong wherever it sits.
+ # It renders broken rather than absent, which a visitor reads as a failing build rather than as a stale badge.
+ for dep in model.get("deprecatedShields", []):
+ for ref, url in sorted(defs.items()):
+ if dep["match"] in url:
+ findings.append(("LETTER", f"readme: `[{ref}]` renders {dep['label']}, which is retired - {dep['reason']} (spec/readme-structure.md)"))
+ targets = {(p.get("target") if isinstance(p, dict) else p) for p in entry.get("publish", [])}
+ secrets = set(entry.get("requiredSecrets", []))
+ want = []
+ for cls in model["shieldClasses"]:
+ t = cls["trigger"]
+ if (t["kind"] == "always" or (t["kind"] == "publish" and t.get("target") in targets)
+ or (t["kind"] == "secret" and t.get("name") in secrets)):
+ want += cls["shields"]
+ for sh in want:
+ region = addressed_region(text, sh["in"])
+ if region is None:
+ continue
+ if not any(shield_matches(u, sh) for u in shield_endpoints(region, defs)):
+ findings.append(("LETTER", f"readme: `{sh['in']}` carries no {sh['label']} shield ({sh['match']}), which this repo's deliverables require (spec/readme-structure.md)"))
+ if not sh.get("exclusive"):
+ continue
+ # Rendered elsewhere, which for the license shield is the whole point: it belongs at the bottom and nowhere else.
+ # Compared over uses rather than over raw text, so the shield's own `[ref]: url` definition is not read as a second placement.
+ outside = strip_sections(_HTML_COMMENT.sub("", unfenced_text(text)), [sh["in"].split(">")[0].strip()])
+ if any(shield_matches(u, sh) for u in shield_endpoints(outside, defs)):
+ findings.append(("LETTER", f"readme: the {sh['label']} shield is rendered outside `{sh['in']}` - it belongs there and nowhere else (spec/readme-structure.md)"))
+ return findings
+
+
def workspace_cspell_words(text):
"""True if workspace/settings JSON carries its own cSpell word list - the block cspell.json canonicalizes.
@@ -837,41 +1269,43 @@ def audit_repo(entry, spec, branch=None):
findings.append(("DRIFT", f"carried: {path} references the template repo by name or link outside its verbatim sections (the coordination flow is machinery this repo's readers should not see; state the behavior, not the destination)"))
# --- HISTORY.md mirrors the README opening ---
- # Per spec/readme-structure.md "HISTORY.md", the changelog opens as the README's twin, carrying the same H1 title and the same intro paragraph.
+ # Per spec/readme-structure.md "HISTORY.md", the changelog opens as the README's twin, carrying the same H1 title and the same tagline.
+ # The mirror is the tagline alone, not the whole intro region: a README may carry further clarifying paragraphs, and HISTORY.md does not repeat them.
# It is checked only where both files were readable, since absence is already a file LETTER above.
if "README.md" in doc_texts and "HISTORY.md" in doc_texts:
r_title, r_intro = title_and_intro(doc_texts["README.md"])
h_title, h_intro = title_and_intro(doc_texts["HISTORY.md"])
if r_title != h_title:
findings.append(("LETTER", f"history: HISTORY.md title '{h_title}' does not match README.md title '{r_title}' - the changelog opens as the README's twin (spec/readme-structure.md)"))
- elif r_intro != h_intro:
- findings.append(("LETTER", "history: HISTORY.md intro does not mirror the README intro - copy the README's opening paragraph (spec/readme-structure.md)"))
+ elif tagline(r_intro) != tagline(h_intro):
+ findings.append(("LETTER", "history: HISTORY.md tagline does not mirror the README tagline - copy the README's first line after the H1 (spec/readme-structure.md)"))
# --- README title and intro are the one canonical short description ---
# Per spec/readme-structure.md item 1 and GOVERNANCE.md "Repository Details", the H1 is the repo name.
- # The intro line after it is a link-free plain sentence of at most 100 characters that carries verbatim to the GitHub About description, and on a docker repo to the Docker Hub short description.
+ # The tagline after it, the first line of the intro region, is a link-free plain sentence of at most 100 characters that carries verbatim to the GitHub About description, and on a docker repo to the Docker Hub short description.
+ # Any further paragraph is free prose no mirror reads, which is why only the first line is measured.
# The README is the source of truth.
if "README.md" in doc_texts:
title, intro = title_and_intro(doc_texts["README.md"])
- intro_line = intro.split("\n")[0]
+ intro_line = tagline(intro)
# The H1 is the repository name, and a hyphenated name may render its hyphens as spaces.
# Use the GitHub API's canonical name, since the registry-URL slug can carry a different case.
repo_name = live.get("name") or slug.split("/")[-1]
if not title:
- findings.append(("LETTER", "readme: no `# ` H1 title - the README opens with `# ` then a one-line description (spec/readme-structure.md)"))
+ findings.append(("LETTER", "readme: no `# ` H1 title - the README opens with `# ` then the tagline (spec/readme-structure.md)"))
elif title.replace("-", " ") != repo_name.replace("-", " "):
findings.append(("LETTER", f"readme: the H1 title '{title}' is not the repo name '{repo_name}' (a hyphenated name may render its hyphens as spaces) - the H1 is the repository name (spec/readme-structure.md)"))
if not intro_line:
- findings.append(("LETTER", "readme: no intro line after the H1 - the README opens with the title then a one-line description, which doubles as the About description (spec/readme-structure.md)"))
+ findings.append(("LETTER", "readme: no tagline after the H1 - the README opens with the title then a one-line description, which doubles as the About description (spec/readme-structure.md)"))
else:
if strip_md_links(intro_line) != intro_line:
- findings.append(("LETTER", "readme: the intro line carries Markdown links - keep it link-free plain text, it doubles as the repo About description (spec/readme-structure.md)"))
+ findings.append(("LETTER", "readme: the tagline carries Markdown links - keep it link-free plain text, it doubles as the repo About description (spec/readme-structure.md)"))
want = strip_md_links(intro_line).strip()
if len(want) > 100:
- findings.append(("LETTER", f"readme: the intro line is {len(want)} characters, over the 100-char limit (Docker Hub's short-description cap, the tightest surface it feeds) - tighten it to one short sentence (spec/readme-structure.md)"))
+ findings.append(("LETTER", f"readme: the tagline is {len(want)} characters, over the 100-char limit (Docker Hub's short-description cap, the tightest surface it feeds) - tighten it to one short sentence (spec/readme-structure.md)"))
desc = (live.get("description") or "").strip()
if desc != want:
- findings.append(("LETTER", f"description: the About description does not match the README intro line (description '{desc}' vs readme '{want}') - set it from the README, or sharpen the README first if the description carries real detail (GOVERNANCE.md Repository Details)"))
+ findings.append(("LETTER", f"description: the About description does not match the README tagline (description '{desc}' vs readme '{want}') - set it from the README, or sharpen the README first if the description carries real detail (GOVERNANCE.md Repository Details)"))
# Docker Hub short description mirrors the same intro, for a repo that publishes a docker image.
# A transient lookup failure surfaces as a DRIFT ("could not verify"), never aborting or silently passing.
# A 404 (image not at the derived name) returns None and is skipped.
@@ -882,7 +1316,19 @@ def audit_repo(entry, spec, branch=None):
dh = None
findings.append(("DRIFT", f"description: could not read the Docker Hub short description to verify it mirrors the README ({e}) - verify by hand"))
if dh is not None and dh.strip() != want:
- findings.append(("LETTER", f"description: the Docker Hub short description ('{dh.strip()}') does not match the README intro ('{want}') - set it from the README (spec/readme-structure.md)"))
+ findings.append(("LETTER", f"description: the Docker Hub short description ('{dh.strip()}') does not match the README tagline ('{want}') - set it from the README (spec/readme-structure.md)"))
+
+ # --- README section order, shield classes, and license-shield placement ---
+ # The readme-structure dimension, driven by the declared model in spec/readme-sections.json rather than by prose.
+ # Sited after the title and intro checks so one README read serves both, and guarded on the model being loaded, since the selftest builds a spec without it.
+ readme_model = spec.get("readme")
+ if readme_model and "README.md" in doc_texts:
+ readme_text = doc_texts["README.md"]
+ findings += readme_section_findings(readme_text, readme_model, sel, not live.get("private", False))
+ findings += readme_shield_findings(readme_text, readme_model, entry)
+ findings += readme_link_findings(readme_text, readme_model, slug)
+ if spec.get("tools"):
+ findings += third_party_tool_findings(readme_text, spec["tools"])
# --- cspell single source of truth ---
# Per CODESTYLE.md "Markdown and Spelling", cspell.json is the one word list, and a cSpell words block left in a *.code-workspace duplicates it and silently drifts.
@@ -1193,6 +1639,203 @@ def _selftest():
else:
print(" ok missing catalog: raises and names spec/project-types.json")
+ # The README structure engine, run against the real declared model rather than a fixture of it.
+ # A model edit that contradicts these checks then fails here instead of on the fleet.
+ rm = load("spec/readme-sections.json")
+ conformant = (
+ "# Fixture\n\nA fixture repository.\n\n## Build and Distribution\n\n- **Source Code**: [GitHub][gh]\n\n"
+ "### Build Status\n\n[![Release Status][a]][x]\\\n[![Last Commit][b]][x]\n\n"
+ "### Releases\n\n[![GitHub Release][c]][x]\\\n[![GitHub Pre-Release][d]][x]\n\n"
+ "### Release Notes\n\n**Version**: 1.0\n\n## Table of Contents\n\n- [Overview](#overview)\n\n"
+ "## Overview\n\nWhat it does.\n\n## Whatever This Repo Calls It\n\nRepo-specific.\n\n"
+ "## Questions or Issues\n\nOpen an issue.\n\n## 3rd Party Tools\n\n- [Thing][t]\n\n"
+ "## License\n\nLicensed under the [MIT License][license]\\\n![License][license-shield]\n\n"
+ "\n\n"
+ "[a]: https://img.shields.io/github/actions/workflow/status/o/r/publish-release.yml?label=Releases%20Build\n"
+ "[b]: https://img.shields.io/github/last-commit/o/r\n"
+ "[c]: https://img.shields.io/github/v/release/o/r?label=GitHub%20Release\n"
+ "[d]: https://img.shields.io/github/v/release/o/r?include_prereleases&label=GitHub%20Pre-Release\n"
+ "[license-shield]: https://img.shields.io/github/license/o/r\n"
+ )
+ # The four repos that suffix every heading for the ToC extension must read identically to the plain form.
+ omit_toc = re.sub(r"^(#{2,3} .*)$", r"\1 ", conformant, flags=re.M)
+ readme_cases = [
+ ("a conformant README, repo-specific section included", conformant, set(), True, 0),
+ ("a second intro paragraph is not a finding", conformant.replace("A fixture repository.\n", "A fixture repository.\n\nAnd a clarifying paragraph about it.\n"), set(), True, 0),
+ ("a retired section name reports a rename", conformant.replace("## Overview", "## Features"), set(), True, 1),
+ ("Questions or Issues is optional while private", conformant.replace("## Questions or Issues\n\nOpen an issue.\n\n", ""), set(), False, 0),
+ ("Questions or Issues is required when public", conformant.replace("## Questions or Issues\n\nOpen an issue.\n\n", ""), set(), True, 1),
+ ("3rd Party Tools is required in every repo", conformant.replace("## 3rd Party Tools\n\n- [Thing][t]\n\n", ""), set(), True, 1),
+ ("a Table of Contents is required with no size threshold", conformant.replace("## Table of Contents\n\n- [Overview](#overview)\n\n", ""), set(), True, 1),
+ ("an out-of-order declared section reports once", conformant.replace("## Questions or Issues\n\nOpen an issue.\n\n", "").replace("## Overview", "## Questions or Issues\n\nOpen an issue.\n\n## Overview"), set(), True, 1),
+ ("License must be the last section", conformant + "\n## TODO\n\nA backlog.\n", set(), True, 1),
+ ("a missing Release Notes sub-section reports once", conformant.replace("### Release Notes\n\n**Version**: 1.0\n\n", ""), set(), True, 1),
+ ("a `## ` line inside a fence is not a section", conformant.replace("What it does.", "```md\n## Not A Section\n```"), set(), True, 0),
+ ("Usage and Installation are N/A for source-only", conformant, {"source-only"}, True, 0),
+ ("a ToC-omit comment on every heading changes nothing", omit_toc, set(), True, 0),
+ ]
+ for label, text, sel_types, public, wantn in readme_cases:
+ got = readme_section_findings(text, rm, sel_types, public)
+ if len(got) != wantn:
+ ok = False
+ print(f" {'ok ' if len(got) == wantn else 'FAIL'} want={wantn} got={len(got)} readme sections: {label}")
+ if len(got) != wantn:
+ for _, t in got:
+ print(f" {t}")
+
+ docker_shield = "[![Docker Latest][e]][x]\n[e]: https://img.shields.io/docker/v/o/r/latest\n"
+ inline_all = re.sub(r"!\[([^\]]*)\]\[([a-z-]+)\]", lambda m: f"})", conformant)
+ shield_cases = [
+ ("base shields only, a repo publishing nothing", conformant, {}, 0),
+ ("a ToC-omit comment on every heading changes nothing", omit_toc, {}, 0),
+ ("a docker repo owes a version shield", conformant, {"publish": [{"target": "docker"}]}, 1),
+ ("a docker repo carrying one is satisfied, whatever its channel", conformant.replace("[![GitHub Pre-Release][d]][x]\n", "[![GitHub Pre-Release][d]][x]\\\n" + docker_shield), {"publish": [{"target": "docker"}]}, 0),
+ ("a nuget repo owes one version shield, no prerelease", conformant, {"publish": [{"target": "nuget"}]}, 1),
+ ("a pypi repo owes one version shield, no prerelease", conformant, {"publish": [{"target": "pypi"}]}, 1),
+ ("a caption the repo spells differently is not a finding", conformant.replace("![Release Status]", "![Lint Build]"), {}, 0),
+ ("an extra shield beyond the class is not a finding", conformant.replace("[![Last Commit][b]][x]", "[![Last Commit][b]][x]\\\n[![Last Build][a]][x]"), {}, 0),
+ ("a shield in the wrong sub-section does not count as present", conformant.replace("[![GitHub Release][c]][x]\\\n", ""), {}, 1),
+ # A badge shown as a code sample is markup, so it satisfies nothing and trips nothing.
+ # Every region the README checks read comes through readme_region, which strips fences at the root.
+ ("a fenced badge sample does not satisfy a required shield", conformant.replace("[![GitHub Release][c]][x]\\\n", "```md\n[![GitHub Release][c]][x]\n```\n"), {}, 1),
+ ("a fenced license shield does not trip the exclusive rule", conformant.replace("## Overview", "```md\n![License][license-shield]\n```\n\n## Overview"), {}, 0),
+ ("a retired badge service is reported wherever it sits", conformant.replace("[license-shield]: https://img.shields.io/github/license/o/r\n", "[license-shield]: https://img.shields.io/github/license/o/r\n[last-build-shield]: https://byob.yarr.is/o/r/lastbuild\n"), {}, 1),
+ ("the pre-release shield is told from the release shield by its query", conformant.replace("?include_prereleases&label=GitHub%20Pre-Release", "?label=Another%20Release"), {}, 1),
+ # The license shield is an ordinary member of the base class, addressed to a different section.
+ ("the license shield in the closing License section", conformant, {}, 0),
+ ("no license shield at all", conformant.replace("\\\n![License][license-shield]", ""), {}, 1),
+ ("the license shield left under Releases", conformant.replace("\\\n![License][license-shield]", "").replace("[![GitHub Pre-Release][d]][x]", "[![GitHub Pre-Release][d]][x]\\\n![License][license-shield]"), {}, 2),
+ ("a shield's own reference definition is not a use of it", conformant.replace("![License][license-shield]\n", ""), {}, 1),
+ # An inline shield is resolved rather than silently invisible.
+ # Reading references alone let a repo writing every badge inline pass by carrying nothing the check could see.
+ ("inline shields count as present", inline_all.replace("PLACEHOLDER-a", "github/actions/workflow/status/o/r").replace("PLACEHOLDER-b", "github/last-commit/o/r").replace("PLACEHOLDER-c", "github/v/release/o/r").replace("PLACEHOLDER-d", "github/v/release/o/r?include_prereleases").replace("PLACEHOLDER-license-shield", "github/license/o/r"), {}, 0),
+ ("an inline shield in the wrong section is still exclusive", conformant.replace("## Overview", "\n\n## Overview"), {}, 1),
+ ]
+ for label, text, ent, wantn in shield_cases:
+ got = readme_shield_findings(text, rm, ent)
+ if len(got) != wantn:
+ ok = False
+ print(f" {'ok ' if len(got) == wantn else 'FAIL'} want={wantn} got={len(got)} readme shields: {label}")
+ if len(got) != wantn:
+ for _, t in got:
+ print(f" {t}")
+
+ links_ok = (
+ "# F\n\nA fixture.\n\n## X\n\n[a](#x) [b][gh] [c][docker-hub-link] [d][agents] [e][upstream-link]\n\n"
+ "![License][license-shield]\n\n"
+ "\n\n[x-anchor]: #x\n\n\n\n[license-shield]: https://img.shields.io/github/license/o/r\n\n"
+ "\n\n[actions-link]: https://github.com/o/r/actions\n[docker-hub-link]: https://hub.docker.com/r/o/r\n[github-link]: https://github.com/o/r\n\n"
+ "\n\n[agents]: ./AGENTS.md\n[license]: ./LICENSE\n\n"
+ "\n\n[upstream-link]: https://github.com/someone-else/their-repo\n"
+ )
+ # Kept in reference-name order, so the case measures the per-target rule rather than tripping the sort check.
+ two_images = links_ok.replace(
+ "[github-link]: https://github.com/o/r\n",
+ "[github-link]: https://github.com/o/r\n[lsio-docker-hub-link]: https://hub.docker.com/r/o/r-lsio\n")
+ swapped = links_ok.replace(
+ "\n\n[x-anchor]: #x\n\n\n\n[license-shield]: https://img.shields.io/github/license/o/r\n",
+ "\n\n[license-shield]: https://img.shields.io/github/license/o/r\n\n\n\n[x-anchor]: #x\n")
+ link_cases = [
+ ("a conformant reference block", links_ok, 0, 0),
+ ("a shield reference not ending -shield", links_ok.replace("[license-shield]", "[licence]"), 1, 0),
+ ("a URI reference not ending -link", links_ok.replace("[upstream-link]", "[upstream]"), 1, 0),
+ ("a repo-local reference ending -link", links_ok.replace("[agents]:", "[agents-link]:"), 1, 0),
+ ("the repo root named for the project", links_ok.replace("[github-link]", "[fixture-link]"), 1, 0),
+ ("somebody else's GitHub repo is not renamed", links_ok, 0, 0),
+ ("one Docker Hub image takes the bare name", links_ok, 0, 0),
+ ("two Docker Hub images each take a target prefix", two_images, 1, 0),
+ ("two Docker Hub images, both prefixed, is clean", two_images.replace("[docker-hub-link]: https://hub.docker.com/r/o/r\n", "[base-docker-hub-link]: https://hub.docker.com/r/o/r\n"), 0, 0),
+ ("an undeclared group header", links_ok.replace("", ""), 0, 1),
+ ("groups out of the declared order", swapped, 0, 1),
+ ("a group not sorted by reference name", links_ok.replace("[agents]: ./AGENTS.md\n[license]: ./LICENSE\n", "[license]: ./LICENSE\n[agents]: ./AGENTS.md\n"), 0, 1),
+ ("a reference in the wrong group", links_ok.replace("[license]: ./LICENSE\n", "").replace("\n", "\n\n[license]: ./LICENSE\n"), 0, 1),
+ # A shields.io URL the document never renders is judged by that usage rather than by its host, so it is an ordinary URI.
+ # Across the fleet every one of the 119 img.shields.io definitions is rendered, so a host test decides nothing.
+ ("an unrendered shields.io reference is a URI, not a shield", links_ok.replace("[license-shield]: https://img.shields.io/github/license/o/r\n", "[extra-shield]: https://img.shields.io/badge/never-rendered-blue\n[license-shield]: https://img.shields.io/github/license/o/r\n"), 1, 1),
+ # A code sample showing badge markup is markup rather than a badge.
+ # Reading a fenced block would invent a group header, two definitions, and a rendered shield the document does not have.
+ ("a fenced code sample is not read as definitions", links_ok + "\n## Sample\n\n```md\n\n\n[wrong-name]: https://example.test/\n![License][license-shield]\n```\n", 0, 0),
+ # A dependency hosted where this project also publishes stays external, so it keeps its own name.
+ # The owner-scoped prefixes are what separate the two, and they live in the model rather than in the code.
+ ("somebody else's NuGet package is not renamed nuget-link", links_ok.replace("[upstream-link]: https://github.com/someone-else/their-repo", "[upstream-link]: https://www.nuget.org/packages/Serilog/"), 0, 0),
+ ("this project's own NuGet package is renamed", links_ok.replace("[upstream-link]: https://github.com/someone-else/their-repo", "[upstream-link]: https://www.nuget.org/packages/o.Widget/"), 1, 1),
+ # A comment that opens no group is not a group header.
+ # A markdownlint directive sits on its own line exactly like one, and reading it as a group reports a header the document never declared.
+ ("a directive comment is not a link group", "\n" + links_ok, 0, 0),
+ # Also when it sits inside the reference block, where definitions do fall under it.
+ ("a directive inside the reference block is not a link group", links_ok.replace("[agents]: ./AGENTS.md", "\n[agents]: ./AGENTS.md"), 0, 0),
+ # A scheme makes a target a link rather than a file, so it is named `-link` and grouped External.
+ # Reading `mailto:` as a repo path would demand a bare name and the Repo group for a contact address.
+ ("a mailto target is a URI, not a repo path", links_ok.replace("[upstream-link]: https://github.com/someone-else/their-repo", "[upstream-link]: mailto:someone@example.test"), 0, 0),
+ ("a mailto target named as a repo path is reported", links_ok.replace("[upstream-link]: https://github.com/someone-else/their-repo", "[upstream]: mailto:someone@example.test"), 1, 0),
+ # Two names may point at one URL, and only the rendered one is a shield.
+ # Keying on the URL instead would make the plain link a shield because its twin is rendered.
+ ("a second reference to a rendered URL is not itself a shield", links_ok.replace("[upstream-link]: https://github.com/someone-else/their-repo", "[upstream-link]: https://img.shields.io/github/license/o/r"), 0, 0),
+ ]
+ for label, text, want_letter, want_drift in link_cases:
+ got = readme_link_findings(text, rm, "o/r")
+ gl = sum(1 for k, _ in got if k == "LETTER")
+ gd = sum(1 for k, _ in got if k == "DRIFT")
+ good = (gl, gd) == (want_letter, want_drift)
+ if not good:
+ ok = False
+ print(f" {'ok ' if good else 'FAIL'} want={want_letter}L/{want_drift}D got={gl}L/{gd}D readme links: {label}")
+ if not good:
+ for k, t in got:
+ print(f" {k}: {t}")
+
+ # The shared tool catalog, checked over the intersection only: a repo's own tools are its own business.
+ cat = load("spec/third-party-tools.json")
+ tools_head = "# F\n\nA fixture.\n\n## 3rd Party Tools\n\n"
+ tools_defs = "\n[cspell-link]: https://cspell.org\n[widget-link]: https://widget.example/\n"
+ tool_cases = [
+ ("a catalog tool matching the catalog", tools_head + "| Tool | Role |\n| --- | --- |\n| [cspell][cspell-link] | Spell checker. |\n" + tools_defs, 0),
+ ("a catalog tool described differently", tools_head + "| Tool | Role |\n| --- | --- |\n| [cspell][cspell-link] | Spell-checks README.md in CI. |\n" + tools_defs, 1),
+ ("a catalog tool with no description at all", tools_head + "- [cspell][cspell-link]\n" + tools_defs, 1),
+ ("a catalog tool linked by another URL", tools_head + "| Tool | Role |\n| --- | --- |\n| [cspell][widget-link] | Spell checker. |\n" + tools_defs, 1),
+ ("a tool the catalog does not name is not judged", tools_head + "| Tool | Role |\n| --- | --- |\n| [Widget][widget-link] | whatever this repo calls it |\n" + tools_defs, 0),
+ ("the bullet form is read like the table form", tools_head + "- [cspell][cspell-link] - Spell checker.\n" + tools_defs, 0),
+ ("no section yields nothing, since its absence is already reported", "# F\n\nA fixture.\n\n## Other\n\nx\n", 0),
+ # A definition inside a code sample is markup being shown, so it must not resolve a tool link.
+ # The sample sits after the real definition deliberately, so reading across fences would override it.
+ ("a fenced sample does not supply a tool link", tools_head + "| Tool | Role |\n| --- | --- |\n| [cspell][cspell-link] | Spell checker. |\n" + tools_defs + "\n```md\n[cspell-link]: https://wrong.example/\n```\n", 0),
+ # A tool table shown as a sample inside the section is markup, not entries.
+ ("a fenced tool table is not read as entries", tools_head + "| Tool | Role |\n| --- | --- |\n| [cspell][cspell-link] | Spell checker. |\n\n```md\n| [cspell][cspell-link] | wrong description |\n```\n" + tools_defs, 0),
+ # The list is alphabetized, which spec/readme-structure.md states and nothing checked until now.
+ ("an unsorted tool list is reported", tools_head + "| Tool | Role |\n| --- | --- |\n| [markdownlint-cli2][md-link] | Markdown linter. |\n| [cspell][cspell-link] | Spell checker. |\n" + tools_defs + "[md-link]: https://github.com/DavidAnson/markdownlint-cli2\n", 1),
+ ("a sorted tool list is not", tools_head + "| Tool | Role |\n| --- | --- |\n| [cspell][cspell-link] | Spell checker. |\n| [markdownlint-cli2][md-link] | Markdown linter. |\n" + tools_defs + "[md-link]: https://github.com/DavidAnson/markdownlint-cli2\n", 0),
+ ]
+ for label, text, wantn in tool_cases:
+ got = third_party_tool_findings(text, cat)
+ if len(got) != wantn:
+ ok = False
+ print(f" {'ok ' if len(got) == wantn else 'FAIL'} want={wantn} got={len(got)} 3rd party tools: {label}")
+ if len(got) != wantn:
+ for _, t in got:
+ print(f" {t}")
+
+ # A canonicalLinks entry that can match nothing is a name nothing enforces, so it raises rather than skipping.
+ # Skipping would quietly stop checking that destination, which is the failure mode this whole dimension exists to avoid.
+ broken = dict(rm, canonicalLinks=list(rm["canonicalLinks"]) + [{"name": "orphan-link"}])
+ try:
+ canonical_link_entry("https://example.test/", broken, "o/r")
+ ok = False
+ print(" FAIL canonicalLinks entry with neither repoPath nor match: no error raised")
+ except KeyError as e:
+ if "readme-sections.json" not in str(e) or "orphan-link" not in str(e):
+ ok = False
+ print(f" FAIL canonicalLinks entry error does not name the file and entry -> {e}")
+ else:
+ print(" ok canonicalLinks entry with neither repoPath nor match: raises, naming the file and the entry")
+
+ # The tagline is the first line of the intro region, so a README carrying a second paragraph still mirrors.
+ two_para = title_and_intro("# X\n\nThe tagline.\n\nA clarifying paragraph.\n\n## Next\n")[1]
+ if tagline(two_para) != "The tagline." or "\n" in tagline(two_para):
+ ok = False
+ print(f" FAIL tagline extraction -> {tagline(two_para)!r}")
+ else:
+ print(" ok tagline: the first line of the intro region, further paragraphs excluded")
+
# A ground-truth branch that does not resolve is one error, not a baseline's worth of letters.
# Every `?ref=` read would 404 and report each carried file absent, describing the ref, not the repo.
# The branch facts are already read at that point, so they are reported rather than dropped.
@@ -1294,6 +1937,8 @@ def main(argv=None):
"secrets": load("spec/secrets.json"),
"files": load("spec/files.json"),
"types": load("spec/project-types.json"),
+ "readme": load("spec/readme-sections.json"),
+ "tools": load("spec/third-party-tools.json"),
}
issue_mode = a.issue
wanted = {n.lower() for n in a.names}
diff --git a/spec/project-types.json b/spec/project-types.json
index 6afac0c0..2ec362b7 100644
--- a/spec/project-types.json
+++ b/spec/project-types.json
@@ -178,7 +178,11 @@
"readme-structure": {
"appliesTo": "*",
"checks": [
- { "id": "readme.sections", "verdict": "intent", "assert": "The README follows the sections and order in spec/readme-structure.md (applicable sections only).", "intentRef": "spec/readme-structure.md" }
+ { "id": "readme.sections", "verdict": "letter", "assert": "The README carries the required sections and keeps the declared order, per spec/readme-sections.json, which spec/audit.py reads. Required everywhere: Build and Distribution with its Build Status, Releases and Release Notes sub-sections; Table of Contents, with no size threshold; 3rd Party Tools; License as the last section in the file. Required in a public repo: Questions or Issues. The Overview slot has no accepted aliases, so a Use Cases, Features or About heading is a rename finding. A heading the model does not name is dropped before the order comparison, so a genuinely repo-specific section sits anywhere.", "intentRef": "spec/readme-structure.md" },
+ { "id": "readme.shields", "verdict": "letter", "assert": "Every shield the repo's deliverables imply is present in the section that shield names, additive over the base set, per the shieldClasses in spec/readme-sections.json. Each shield carries the section it belongs in, so the license shield is an ordinary base-class shield addressed to the closing License section rather than a rule of its own, and it is marked exclusive, meaning it must render nowhere else. Matched by shields.io endpoint rather than by alt text or reference name, resolving both reference-style and inline images, and a class is a floor, so an extra shield is not a finding.", "intentRef": "spec/readme-structure.md" },
+ { "id": "readme.links", "verdict": "letter", "assert": "Reference definitions live at the foot of the file and are named by what they point at, per linkNaming and canonicalLinks in spec/readme-sections.json: a shield ends -shield, a URI ends -link, an in-page anchor and a repo-local path are bare. A destination every repo has carries the same name everywhere (github-link, actions-link, releases-link, commits-link, issues-link, discussions-link, docker-hub-link, nuget-link, pypi-link), keyed to the repo's own URLs so a third-party link keeps its descriptive name, and a repo publishing several of something prefixes the target rather than renaming (nxmeta-docker-hub-link). Grouping is reported as drift rather than letter, and covers the closed five-group set (Sections, Shields, Distribution, Repo, External), their order, sorting by reference name within a group, and a reference sitting in the group its kind implies.", "intentRef": "spec/readme-structure.md" },
+ { "id": "readme.tools", "verdict": "letter", "assert": "A 3rd Party Tools entry for a tool the fleet catalog names matches that catalog, per spec/third-party-tools.json: the same link and the same description, and the description says what the tool is rather than what this repo does with it. The catalog is a standard set rather than a complete one, so a tool it does not name produces no finding, and adding a tool to it is a hub edit. The section carries no license column.", "intentRef": "spec/readme-structure.md" },
+ { "id": "readme.tagline", "verdict": "letter", "assert": "The first line after the H1 is the tagline: one sentence, link-free plain text, at most 100 characters, mirrored to the GitHub About description, the HISTORY.md opening, and the Docker Hub short description. Further paragraphs below it are free prose under no length or link rule, and no mirror carries them.", "intentRef": "spec/readme-structure.md" }
]
}
}
diff --git a/spec/readme-sections.json b/spec/readme-sections.json
new file mode 100644
index 00000000..ee982aa9
--- /dev/null
+++ b/spec/readme-sections.json
@@ -0,0 +1,86 @@
+{
+ "$schema": "./readme-sections.schema.json",
+ "note": "The declared README section and shield model the audit's readme-structure dimension checks mechanically. Ordinals match the numbered items in spec/readme-structure.md, which is the prose the model encodes; item 1 is the title block rather than a level-two heading, so the section list starts at ordinal 2. The order check drops every heading absent from this list, so a genuinely repo-specific section may sit anywhere, then asserts the surviving ordinals are non-decreasing, every required section is present, and the section carrying last:true is the final level-two heading in the file. Requirement is always, public (a public repository only), or optional; an optional section that is present still holds its ordinal. A section naming notApplicableTo is exempt where the repository carries one of those project types. Shield classes are additive: the base class applies to every repository and each remaining class adds its shields where its trigger fires. A shield is matched on its shields.io endpoint path rather than on its alt text or reference name, because those are captions a repository writes to suit itself and the fleet spells the same badge Release Status, Releases Build, Build Status, Workflow Status and Lint Build against one endpoint, while every one of them renders from /github/actions/workflow/status/. requireQuery and forbidQuery separate two shields sharing an endpoint, which is how the GitHub release and pre-release versions are told apart. Each shield names the section it belongs in, as a heading or a heading and sub-heading, so where a shield lives is data rather than a fixed field name: that is what lets the license shield be an ordinary shield sitting in a different section instead of a second model beside this one. A shield marked exclusive must appear nowhere outside the section it names, which the license shield is the only one to need. A class states a floor rather than a closed set, so an extra shield is never a finding. linkGroups is the closed, ordered set of reference-definition groups at the foot of the file, each holding one kind, and linkNaming gives the suffix a reference name carries for the kind it points at. canonicalLinks fixes the reference name for a destination every repository has, so the same thing is called the same thing everywhere and a reader moving between repositories is not re-learning the names: it is keyed to the repository's own URLs, so a link to somebody else's GitHub repository is an ordinary external link. A destination marked perTarget is one a repository may publish more than one of, and it takes the bare canonical name where there is exactly one and a target-qualified name where there are several, so NxWitness writes nxmeta-docker-hub-link and nxwitness-lsio-docker-hub-link where PlexCleaner writes docker-hub-link. The qualifier is a prefix rather than a new name, which is the whole point: one shape covers one image and twelve.",
+ "sections": [
+ { "name": "Build and Distribution", "ordinal": 2, "required": "always", "subsections": ["Build Status", "Releases", "Release Notes"] },
+ { "name": "Getting Started", "ordinal": 3, "required": "optional" },
+ { "name": "Table of Contents", "ordinal": 4, "required": "always" },
+ { "name": "Overview", "ordinal": 5, "required": "optional", "retiredNames": ["Use Cases", "Features", "About"] },
+ { "name": "Installation", "ordinal": 6, "required": "optional", "notApplicableTo": ["source-only"] },
+ { "name": "Configuration", "ordinal": 7, "required": "optional" },
+ { "name": "Usage", "ordinal": 8, "required": "optional", "notApplicableTo": ["source-only"] },
+ { "name": "Questions or Issues", "ordinal": 9, "required": "public" },
+ { "name": "Development Environment Setup", "ordinal": 10, "required": "optional" },
+ { "name": "Contributing", "ordinal": 11, "required": "optional" },
+ { "name": "3rd Party Tools", "ordinal": 12, "required": "always" },
+ { "name": "Credits", "ordinal": 13, "required": "optional" },
+ { "name": "License", "ordinal": 14, "required": "always", "last": true }
+ ],
+ "directiveComments": ["markdownlint", "prettier-ignore", "cspell:", "editorconfig-checker-", "omit from toc"],
+ "linkGroups": [
+ { "name": "Sections", "holds": "anchor", "note": "In-page anchors, which the auto-generated Table of Contents does not use since it keeps inline links." },
+ { "name": "Shields", "holds": "shield", "note": "Badges, meaning any reference the document renders as an image, whatever host serves it." },
+ { "name": "Distribution", "holds": "distribution", "note": "This project's own presence on a platform: its GitHub repository and the pages under it, its Docker Hub images, its NuGet packages, its PyPI packages. Separate from External because a reader looking for where the project ships should not have to pick it out of a list of third-party URLs." },
+ { "name": "Repo", "holds": "local", "note": "Files and paths inside this repository." },
+ { "name": "External", "holds": "external", "note": "Every other URI, meaning anything that is not this project." }
+ ],
+ "linkNaming": [
+ { "kind": "shield", "suffix": "-shield", "note": "A badge, judged by the document rendering the reference as an image rather than by the host serving it." },
+ { "kind": "distribution", "suffix": "-link", "note": "A URI." },
+ { "kind": "external", "suffix": "-link", "note": "A URI." },
+ { "kind": "anchor", "suffix": "", "note": "An in-page anchor is a bare reference." },
+ { "kind": "local", "suffix": "", "note": "A path inside this repository is a bare reference." }
+ ],
+ "distribution": {
+ "note": "The URL prefixes that make a link this project's own rather than somebody else's, with {slug} the owner/repo pair and {owner} the owner alone. This is the single definition of ownership: link_kind reads it to classify a reference, and a canonicalLinks entry is only ever consulted for a reference already classified distribution. Keeping the two apart matters, because a 3rd Party Tools list is full of other people's GitHub repositories, Docker Hub images and NuGet packages, and a host-only test would rename Serilog's package link to nuget-link. The package prefixes carry the owner because a fleet package is published under the owner's own name, which is what distinguishes it from a dependency on the same host.",
+ "urlPrefixes": [
+ "https://github.com/{slug}",
+ "https://hub.docker.com/r/{owner}/",
+ "https://www.nuget.org/packages/{owner}.",
+ "https://nuget.org/packages/{owner}.",
+ "https://pypi.org/project/{owner}-"
+ ]
+ },
+ "canonicalLinks": [
+ { "name": "github-link", "repoPath": "" },
+ { "name": "actions-link", "repoPath": "/actions" },
+ { "name": "releases-link", "repoPath": "/releases" },
+ { "name": "commits-link", "repoPath": "/commits", "prefix": true },
+ { "name": "issues-link", "repoPath": "/issues" },
+ { "name": "discussions-link", "repoPath": "/discussions" },
+ { "name": "docker-hub-link", "match": "^https://hub\\.docker\\.com/r/", "perTarget": true },
+ { "name": "nuget-link", "match": "^https://(www\\.)?nuget\\.org/packages/", "perTarget": true },
+ { "name": "pypi-link", "match": "^https://pypi\\.org/project/", "perTarget": true }
+ ],
+ "deprecatedShields": [
+ { "match": "byob.yarr.is", "label": "the byob.yarr.is last-build badge", "reason": "The service is deprecated, and two of the three repositories carrying it already render a broken badge, since the endpoint returns 404. No shield class requires a last-build badge, so the fix is to delete the shield and its reference definition rather than to replace it." }
+ ],
+ "shieldClasses": [
+ {
+ "name": "base",
+ "trigger": { "kind": "always" },
+ "shields": [
+ { "label": "build status", "match": "/github/actions/workflow/status/", "in": "Build and Distribution > Build Status" },
+ { "label": "last commit", "match": "/github/last-commit/", "in": "Build and Distribution > Build Status" },
+ { "label": "GitHub release version", "match": "/github/v/release/", "forbidQuery": "include_prereleases", "in": "Build and Distribution > Releases" },
+ { "label": "GitHub pre-release version", "match": "/github/v/release/", "requireQuery": "include_prereleases", "in": "Build and Distribution > Releases" },
+ { "label": "license", "match": "/github/license/", "in": "License", "exclusive": true }
+ ]
+ },
+ {
+ "name": "docker",
+ "trigger": { "kind": "publish", "target": "docker" },
+ "shields": [{ "label": "Docker image version", "match": "/docker/v/", "in": "Build and Distribution > Releases" }]
+ },
+ {
+ "name": "nuget",
+ "trigger": { "kind": "publish", "target": "nuget" },
+ "shields": [{ "label": "NuGet release version", "match": "/nuget/v/", "in": "Build and Distribution > Releases" }]
+ },
+ {
+ "name": "pypi",
+ "trigger": { "kind": "publish", "target": "pypi" },
+ "shields": [{ "label": "PyPI release version", "match": "/pypi/v/", "in": "Build and Distribution > Releases" }]
+ }
+ ]
+}
diff --git a/spec/readme-sections.schema.json b/spec/readme-sections.schema.json
new file mode 100644
index 00000000..169f5235
--- /dev/null
+++ b/spec/readme-sections.schema.json
@@ -0,0 +1,146 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/ptr727/ProjectTemplate/spec/readme-sections.schema.json",
+ "title": "README section and shield model",
+ "type": "object",
+ "required": ["sections", "shieldClasses", "linkGroups", "linkNaming", "canonicalLinks", "distribution"],
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "note": { "type": "string" },
+ "sections": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["name", "ordinal", "required"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "ordinal": { "type": "integer", "minimum": 1 },
+ "required": { "enum": ["always", "public", "optional"] },
+ "last": { "type": "boolean" },
+ "subsections": { "type": "array", "items": { "type": "string", "minLength": 1 } },
+ "retiredNames": { "type": "array", "items": { "type": "string", "minLength": 1 } },
+ "notApplicableTo": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }
+ }
+ }
+ },
+ "directiveComments": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1, "description": "A comment prefix that instructs a tool rather than naming a link group." }
+ },
+ "linkGroups": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["name", "holds"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "holds": { "$ref": "#/$defs/linkKind" },
+ "note": { "type": "string" }
+ }
+ }
+ },
+ "linkNaming": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["kind", "suffix"],
+ "additionalProperties": false,
+ "properties": {
+ "kind": { "$ref": "#/$defs/linkKind" },
+ "suffix": { "type": "string", "description": "The suffix a reference name carries for this kind. An empty string means a bare name." },
+ "note": { "type": "string" }
+ }
+ }
+ },
+ "distribution": {
+ "type": "object",
+ "required": ["urlPrefixes"],
+ "additionalProperties": false,
+ "properties": {
+ "note": { "type": "string" },
+ "urlPrefixes": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "type": "string", "minLength": 1, "description": "A URL prefix making a link the project's own. `{slug}` is owner/repo, `{owner}` is the owner." }
+ }
+ }
+ },
+ "canonicalLinks": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["name"],
+ "anyOf": [{ "required": ["repoPath"] }, { "required": ["match"] }],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "repoPath": { "type": "string", "description": "Matched against the repository's own URL plus this path. An empty string is the repository root." },
+ "match": { "type": "string", "minLength": 1, "description": "A regular expression, for a destination that is not under the repository URL." },
+ "prefix": { "type": "boolean", "description": "Match repoPath as a prefix rather than exactly, for a path carrying a branch or a sub-path." },
+ "perTarget": { "type": "boolean", "description": "The repository may publish more than one of these. One takes the bare name; several each take `-`." }
+ }
+ }
+ },
+ "deprecatedShields": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["match", "label", "reason"],
+ "additionalProperties": false,
+ "properties": {
+ "match": { "type": "string", "minLength": 1, "description": "A substring of the badge URL." },
+ "label": { "type": "string", "minLength": 1 },
+ "reason": { "type": "string", "minLength": 1 }
+ }
+ }
+ },
+ "shieldClasses": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["name", "trigger", "shields"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "trigger": {
+ "type": "object",
+ "required": ["kind"],
+ "additionalProperties": false,
+ "properties": {
+ "kind": { "enum": ["always", "publish", "secret"] },
+ "target": { "type": "string", "minLength": 1 },
+ "name": { "type": "string", "minLength": 1 }
+ }
+ },
+ "shields": { "$ref": "#/$defs/shieldList" }
+ }
+ }
+ }
+ },
+ "$defs": {
+ "linkKind": { "enum": ["shield", "distribution", "external", "anchor", "local"] },
+ "shieldList": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["label", "match", "in"],
+ "additionalProperties": false,
+ "properties": {
+ "label": { "type": "string", "minLength": 1 },
+ "match": { "type": "string", "minLength": 1 },
+ "in": { "type": "string", "minLength": 1, "description": "The section the shield belongs in, as `Heading` or `Heading > Sub-heading`." },
+ "exclusive": { "type": "boolean", "description": "The shield must appear nowhere outside the section it names." },
+ "requireQuery": { "type": "string", "minLength": 1 },
+ "forbidQuery": { "type": "string", "minLength": 1 }
+ }
+ }
+ }
+ }
+}
diff --git a/spec/readme-structure.md b/spec/readme-structure.md
index 2d5a0703..3a8b1e4b 100644
--- a/spec/readme-structure.md
+++ b/spec/readme-structure.md
@@ -2,27 +2,46 @@
The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a repo's README against this: the sections below, in this order (to the letter where a section applies, to intent where a project legitimately has nothing to say). A section that does not apply to a project type is N/A, not a defect (a library has no running-app "Installation", and a source-only repo has no "Usage").
+The order, the requirement, and the shield sets are declared data in [`readme-sections.json`][readme-sections], which is what the audit reads. This file is the prose that model encodes, and the two are edited together. The model constrains the sections it names and nothing else: a heading it does not name is dropped before the order comparison, so a genuinely repo-specific section sits wherever the project wants it, per the recurrence rule in [`section-model.md`][section-model].
+
## Sections and Order
-1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then a one-line description as the next paragraph. That description is a **single sentence, link-free plain text, at most 100 characters**, and it is the one canonical short description. It doubles as the GitHub About description (GOVERNANCE.md "Repository Details") and, for a repo that publishes a Docker image, the Docker Hub short description. Both render no Markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. The audit checks the H1 name, the length, the link-free form, and the mirrors.
-2. **Build and Distribution (`##`)** - a bullet per distribution channel the project actually ships, each linking where it lives: **Source Code** (the GitHub repo), **Versioned Releases** (GitHub Releases), **Docker Images** (Docker Hub), **NuGet Packages** (NuGet.org), **PyPI Packages** (PyPI.org). List only the channels the project uses. It carries three sub-sections:
- - **Build Status (`###`)** - the CI/build status shields (release build, Docker build, last commit, last build).
- - **Releases (`###`)** - the version shields (GitHub release, GitHub pre-release, Docker latest/develop, NuGet, PyPI), one per channel the project publishes.
- - **Release Notes (`###`)** - the current version and a short summary, any breaking-change callout, then a link to `HISTORY.md` for the full history.
+1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then the **tagline** as the first line of the next paragraph. The tagline is a **single sentence, link-free plain text, at most 100 characters**, and it alone is the canonical short description: it carries to the GitHub About description (GOVERNANCE.md "Repository Details"), to the `HISTORY.md` opening, and, for a repo that publishes a Docker image, to the Docker Hub short description. Those surfaces render no Markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. **Further paragraphs below the tagline are free prose**, carrying whatever a reader needs to understand the project before the fold, under no length or link rule and read by no mirror. The rule is scoped to the one line the mirrors take precisely so that a README is not forbidden from saying anything more about itself above the fold. The audit checks the H1 name, the tagline's length and link-free form, and the mirrors.
+2. **Build and Distribution (`##`)** - a bullet per distribution channel the project actually ships, each linking where it lives: **Source Code** (the GitHub repo), **Versioned Releases** (GitHub Releases), **Docker Images** (Docker Hub), **NuGet Packages** (NuGet.org), **PyPI Packages** (PyPI.org). List only the channels the project uses, and name a channel for what it ships, so a repo publishing executables writes Binary Releases where one publishing source archives writes Versioned Releases. It carries three sub-sections:
+ - **Build Status (`###`)** - the CI and build status shields.
+ - **Releases (`###`)** - the version shields, one group per channel the project publishes, per "Shields" below.
+ - **Release Notes (`###`)** - the current version and a short summary, any breaking-change callout, then a link to `HISTORY.md` closing with the wording that it holds the complete release notes and older versions.
3. **Getting Started (`##`)** - *optional.* The shortest path to using the project. The **default is to omit it and go straight to the Table of Contents**, and include Getting Started only when it is short and genuinely helpful, and skip it when it would be long or complex.
-4. **Table of Contents (`##`)** - generated by the Markdown All in One extension and auto-updated on save, so leave the `## Table of Contents` heading in place and let the extension fill and maintain the list. Its inline anchor links are the one exception to the reference-link rule.
-5. **Use Cases (`##`)** - what problems it solves (optional for a library whose purpose is obvious from the description).
+4. **Table of Contents (`##`)** - **every README carries one**, with no size threshold, since navigability is the point and a threshold makes the shape depend on a length that changes. Generated by the Markdown All in One extension and auto-updated on save, so leave the `## Table of Contents` heading in place and let the extension fill and maintain the list. Its inline anchor links are the one exception to the reference-link rule.
+5. **Overview (`##`)** - *optional.* What the project is and what problems it solves. **This heading has no accepted aliases**: `Use Cases`, `Features`, and `About` are the same slot under a different name, and the audit reports each as a rename. One name is chosen because four spellings of one section make the slot unrecognizable to a reader moving between repos, and `Overview` is the one that reads correctly for a configuration or hardware repo as well as for an application.
6. **Installation (`##`)** - how to install or pull it, per channel. N/A for source-only.
7. **Configuration (`##`)** - settings, environment, config files. N/A when there is nothing to configure.
8. **Usage (`##`)** - commands, API, or integration. For a CLI, sub-sections such as `### Command Quick Reference`, `### Global Options`, and a test/example command. N/A for source-only.
-9. **Questions or Issues (`##`)** - where to file issues and ask questions.
-10. **Development Environment Setup (`##`)** - build, test, and lint locally; commit-signing; devcontainer. Point at shared docs rather than restating them.
-11. **3rd Party Tools (`##`)** - notable dependencies and their licenses, where relevant.
-12. **License (`##`)** - a pointer to `LICENSE`.
+9. **Questions or Issues (`##`)** - where to file issues and ask questions. **Required in a public repo**, and optional in a private one, since a private repo has no audience to route.
+10. **Development Environment Setup (`##`)** - *optional.* Build, test, and lint locally; commit-signing; devcontainer. Point at shared docs rather than restating them.
+11. **Contributing (`##`)** - *optional.* The branching workflow, the code style, and the repository setup, each pointing at [`WORKFLOW.md`][workflow], [`CODESTYLE.md`][codestyle], and `repo-config/README.md` rather than restating them.
+12. **3rd Party Tools (`##`)** - the notable dependencies, actions, and tools the project uses, alphabetized, each linked and paired with a **short description of what the tool is**. Required, since every project stands on something and a reader has no other place to find what. The section opens with one line, **"The third-party tools, libraries, and actions this project depends on."**, and that wording is the same in every repo, since a lead-in written per repo is a sentence about this repo's circumstances sitting where a reader expects a list. Each description is written as a sentence, opening with a capital and closing with a full stop. Describe the tool rather than this repo's use of it: "Spell checker." rather than "spell-checks `README.md` and `HISTORY.md` in CI", and "Version computation from git height." rather than a named config file. A role written against local wiring is a second copy of a fact that lives in the workflow, so it goes stale on its own schedule and reads as fleet divergence when the next repo wires the same tool differently. Carry no license column: a license belongs to the dependency and is authoritative at its source, so restating it here adds a maintenance obligation and no information. **A tool the fleet shares is named, linked, and described identically everywhere it appears**, from the catalog in [`third-party-tools.json`][third-party-tools], which is a standard set rather than a complete one: most of a repo's tools are its own and the audit says nothing about those, but a repo using a cataloged tool matches its link and its description. A tool becomes shared by being added to the catalog, which is a hub edit rather than a finding on a repo.
+13. **Credits (`##`)** - *optional, and present when the project is directly based on someone else's work.* What it is based on and why it diverged, then which original copyright is retained, in `LICENSE` and `NOTICE`.
+14. **License (`##`)** - **the last section in the file.** It states that the project is licensed under the MIT License, linking `LICENSE`, then carries the license shield, and the link definitions follow it. It closes the file because a license is what a reader checks last and never what they navigate to first.
## Shields
-Shields are not a top-level section. They live under **Build and Distribution** as the **Build Status** and **Releases** sub-sections. Each shield is a reference-style link, with the definitions grouped under `` at the bottom of the file. Stack a group's badges by ending each line except the last with a trailing backslash.
+Shields live under **Build and Distribution**, as the **Build Status** and **Releases** sub-sections, with one exception: **the license shield sits in the closing License section**, below the license line. Each shield is a reference-style link, with the definitions grouped under `` at the bottom of the file. Stack a group's badges by ending each line except the last with a trailing backslash.
+
+The sets are **additive by deliverable**. Every repo carries the base group, and each publishing channel adds its own on top, which is why the label is per-channel rather than one fixed list:
+
+| Class | Applies when | Build Status | Releases |
+| --- | --- | --- | --- |
+| Base | every repo | workflow status, last commit | GitHub release, GitHub pre-release |
+| Docker | the repo publishes a Docker image | the Docker build status, where the image builds separately | a version shield per image and channel, `latest` and `develop` for a single-image repo |
+| NuGet | the repo publishes to NuGet.org | none | the NuGet release version |
+| PyPI | the repo publishes to PyPI.org | none | the PyPI release version |
+
+**NuGet and PyPI carry one version shield each, not a stable-and-prerelease pair**, and that asymmetry with GitHub and Docker is deliberate. A GitHub pre-release and a Docker `develop` tag are each a separately addressable channel a shield can point at. A NuGet or PyPI prerelease is a property of the version string with no separate address: shields.io publishes no PyPI prerelease badge at all, and its `nuget/vpre` endpoint renders the newest version *including* prereleases, which is the stable version whenever that is newest. A badge that duplicates the one beside it is worse than an absent one, because it reads as current.
+
+The audit matches a shield by its **shields.io endpoint**, never by its alt text or its reference name, because those are captions each repo writes to suit itself. A class states a floor rather than a closed set, so an extra shield is never a finding.
+
+A **retired badge service** is the one exception to that floor, and the audit reports it wherever it sits. A dead badge renders broken rather than absent, which reads as current to a visitor who has no way to tell a stale badge from a failing build. The retired list is `deprecatedShields` in [`readme-sections.json`][readme-sections], and it holds `byob.yarr.is`, whose last-build badge three repos still carry and two of them already serve as a 404. No class requires a last-build badge, so the fix is to delete the shield and its reference definition rather than to find a replacement.
**Repository visibility does not change the structure.** A private repo carries the same sections and the same shield set as a public one, written in full. A private repo cannot be read by shields.io, so its GitHub-sourced badges render broken until it goes public, and that is accepted rather than worked around. The alternative, writing a reduced README while private and adding the missing shields at the visibility flip, makes the shape depend on a property that is temporary by design, since a repo is usually private precisely **while** it is being made presentable, which is exactly when its README is written. Carrying the full shape means going public is a visibility change and nothing else, with no README work owed at the flip and no second shape for the audit to accept.
@@ -34,7 +53,35 @@ Shields are not a top-level section. They live under **Build and Distribution**
- US English and straight quotes. Judge a non-ASCII character against the three tiers in GOVERNANCE.md "Character Set", and restructure a sentence rather than joining its clauses with an em dash or with a spaced hyphen, which that section bans in the same terms.
- Verify every quantitative claim (counts, versions, supported platforms) against current code.
- A project README describes only that project, with no cross-repo references and no template or inheritance framing.
-- Reference-style links only: every URI is a reference link defined at the bottom of the file, grouped by type under an HTML-comment header (``, ``, ``, ``) and alphabetized within each group by the reference name alone, the text inside the brackets, so a shorter name sorts above one it prefixes (`[governance]` above `[governance-branching-model]`) rather than below it as sorting the whole definition line would put it. The auto-generated Table of Contents is the one exception, keeping inline anchor links.
+- Reference-style links only: every URI is a reference link defined at the bottom of the file, grouped and named per "Reference Links" below. The auto-generated Table of Contents is the one exception, keeping inline anchor links.
+
+## Reference Links
+
+Every reference definition lives at the foot of the file, in one of five groups, in this order, each introduced by an HTML-comment header. The set is closed: a group not on this list is drift, and the names are fixed so the block looks the same in every repo. The groups and the naming below are declared in [`readme-sections.json`][readme-sections], which is what the audit reads.
+
+| Group | Holds |
+| --- | --- |
+| `` | In-page anchors. |
+| `` | Badges, meaning any reference the document renders as an image. |
+| `` | This project's own presence on a platform: its GitHub repository and the pages under it, its Docker Hub images, its NuGet packages, its PyPI packages. |
+| `` | Files and paths inside this repository. |
+| `` | Every other URI, meaning anything that is not this project. |
+
+Distribution is separate from External because a reader looking for where the project ships should not have to pick those links out of a list of third-party URLs, and because they are the ones every repo has and can therefore name identically.
+
+**Within a group, definitions are sorted by the reference name alone**, the text inside the brackets, so a shorter name sorts above one it prefixes (`[governance]` above `[governance-branching-model]`) rather than below it as sorting the whole definition line would put it.
+
+**A reference name says what it points at, by its suffix:**
+
+- A **shield** ends `-shield`. This is judged by how the document uses the reference, not by which host serves it, so a badge from a host other than shields.io is still a shield.
+- A **URI** ends `-link`.
+- An **in-page anchor** and a **path inside this repository** are bare, with no suffix.
+
+**A destination every repository has carries the same name in every repository**, so the reference is `github-link` and never `plexcleaner-link` or `projecttemplate-link`. A reader moving between repos should not be re-learning names for the same six pages. The fixed names are `github-link` (the repository root), `actions-link`, `releases-link`, `commits-link`, `issues-link`, `discussions-link`, `docker-hub-link`, `nuget-link`, and `pypi-link`. This is keyed to the repository's own URLs, so a link to somebody else's GitHub repository or Docker Hub image, which a 3rd Party Tools list is full of, is an ordinary external link keeping its own descriptive name.
+
+**Where a repository publishes more than one of something, the name takes a target prefix rather than a new shape**: one Docker Hub image is `docker-hub-link`, and several are `nxmeta-docker-hub-link` and `nxwitness-lsio-docker-hub-link`. The same rule covers NuGet and PyPI. One shape covers a repo shipping one image and a repo shipping twelve, which is the point of prefixing rather than renaming.
+
+The audit reports a naming defect to the letter and a grouping defect as drift. That split is a measurement rather than a judgment about importance: 119 of 122 shield references across the fleet already end `-shield` and 514 of 532 URI references already end `-link`, so the naming rule is being written down rather than imposed, while the fleet carries seventeen distinct group-header names across twenty-two repos, and gating that at once would bury the naming findings under a re-grouping sweep.
## TODO.md
@@ -44,12 +91,18 @@ The file is the declared destination rather than a required file, the same footi
## HISTORY.md
-The rule lives in [`CODESTYLE.md`][codestyle] "Markdown and Spelling", which every repo carries, so a repo can read the rule it is measured against. This file states only what the audit does with it: the `readme-structure` dimension checks that the `HISTORY.md` title and intro match the README's, with HTML comments stripped.
+The rule lives in [`CODESTYLE.md`][codestyle] "Markdown and Spelling", which every repo carries, so a repo can read the rule it is measured against. This file states only what the audit does with it: the `readme-structure` dimension checks that the `HISTORY.md` title and **tagline** match the README's, with HTML comments stripped. The mirror is the tagline alone, so a README carrying further paragraphs below it does not repeat them in the changelog, which opens on its title, its one-line description, and then the releases.
## Docker Hub README
-Docker Hub has two text fields: a **short description** (the tagline, capped near 100 characters) that mirrors the README intro line (item 1), and the longer **overview**. A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README.
+Docker Hub has two text fields: a **short description** (the tagline, capped near 100 characters) that mirrors the README tagline (item 1), and the longer **overview**. A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README.
+
+The short description reaches Docker Hub from the **GitHub About panel**, which the docker-readme task reads as `github.event.repository.description`, rather than from the README. That keeps a Markdown parser out of a publish job, and it means the README-to-About hop is the one a human or the audit closes, not CI. Where About has drifted from the README, the publish faithfully carries the drift onward, which is why the audit checks all three surfaces rather than only the two CI touches.
[codestyle]: ../CODESTYLE.md
+[readme-sections]: ./readme-sections.json
+[section-model]: ./section-model.md
+[third-party-tools]: ./third-party-tools.json
+[workflow]: ../WORKFLOW.md
diff --git a/spec/third-party-tools.json b/spec/third-party-tools.json
new file mode 100644
index 00000000..cba9525b
--- /dev/null
+++ b/spec/third-party-tools.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "./third-party-tools.schema.json",
+ "note": "The shared catalog behind a README's 3rd Party Tools section. A tool a repository uses is named, linked, and described the same way in every repository that uses it, so a reader moving between them recognizes it and a repository writing the section copies rather than invents. The description is written as a sentence, opening with a capital and closing with a full stop, and it says what the tool is, never what this repository does with it: a role written against local wiring is a second copy of a fact that lives in the workflow, it goes stale on its own schedule, and it reads as fleet divergence when the next repository wires the same tool differently. A tool used by one repository today is still declared here, because the point is that the second repository to adopt it finds the wording already written. This is a standard set rather than a complete one: a repository's tools are mostly its own, so a tool absent from this catalog is expected and the audit says nothing about it. What the audit checks is the intersection, meaning that a repository using a tool the catalog names matches the catalog's link and description for it. Extending the catalog is how a tool becomes shared, and it is a hub edit rather than a finding on a repository.",
+ "tools": [
+ { "name": "7-Zip", "link": "https://www.7-zip.org/", "description": "File archiver." },
+ { "name": "AwesomeAssertions", "link": "https://awesomeassertions.org/", "description": "Assertion library for .NET tests." },
+ { "name": "Caddy", "link": "https://caddyserver.com", "description": "Web server." },
+ { "name": "CliWrap", "link": "https://github.com/Tyrrrz/CliWrap", "description": "Process execution library for .NET." },
+ { "name": "Create Pull Request", "link": "https://github.com/marketplace/actions/create-pull-request", "description": "GitHub action that opens a pull request from workspace changes." },
+ { "name": "CSharpier", "link": "https://csharpier.com/", "description": "C# code formatter." },
+ { "name": "cspell", "link": "https://cspell.org", "description": "Spell checker." },
+ { "name": "Docker Hub Description", "link": "https://github.com/marketplace/actions/docker-hub-description", "description": "GitHub action that publishes a Docker Hub repository overview." },
+ { "name": "Docker Run Action", "link": "https://github.com/marketplace/actions/docker-run-action", "description": "GitHub action that runs a step inside a container." },
+ { "name": "dotnet-outdated", "link": "https://github.com/dotnet-outdated/dotnet-outdated", "description": "Outdated NuGet dependency reporter." },
+ { "name": "editorconfig-checker", "link": "https://github.com/editorconfig-checker/editorconfig-checker", "description": "Line-ending and whitespace linter." },
+ { "name": "ExifTool", "link": "https://exiftool.org/", "description": "Media metadata reader and writer." },
+ { "name": "FFmpeg", "link": "https://www.ffmpeg.org/", "description": "Media transcoder." },
+ { "name": "GH Release", "link": "https://github.com/marketplace/actions/gh-release", "description": "GitHub action that creates a release." },
+ { "name": "Git Auto Commit", "link": "https://github.com/marketplace/actions/git-auto-commit", "description": "GitHub action that commits workspace changes." },
+ { "name": "GitHub Actions", "link": "https://github.com/actions", "description": "CI and automation runner." },
+ { "name": "GitHub Dependabot", "link": "https://github.com/dependabot", "description": "Dependency update bot." },
+ { "name": "HandBrake", "link": "https://handbrake.fr/", "description": "Video transcoder." },
+ { "name": "Hugo", "link": "https://gohugo.io", "description": "Static site generator." },
+ { "name": "Husky.Net", "link": "https://alirezanet.github.io/Husky.Net/", "description": "Git hook manager for .NET." },
+ { "name": "ISO 639-2 language tags", "link": "https://www.loc.gov/standards/iso639-2/langhome.html", "description": "Language code standard." },
+ { "name": "ISO 639-3 language tags", "link": "https://iso639-3.sil.org/", "description": "Language code standard." },
+ { "name": "JSON2CSharp", "link": "https://json2csharp.com", "description": "JSON to C# class generator." },
+ { "name": "Markdown All in One", "link": "https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one", "description": "Markdown editing extension." },
+ { "name": "markdownlint-cli2", "link": "https://github.com/DavidAnson/markdownlint-cli2", "description": "Markdown linter." },
+ { "name": "MediaInfo", "link": "https://mediaarea.net/en-us/MediaInfo/", "description": "Media file inspector." },
+ { "name": "MKVToolNix", "link": "https://mkvtoolnix.download/", "description": "Matroska container toolset." },
+ { "name": "NEbml", "link": "https://github.com/OlegZee/NEbml", "description": "EBML reader and writer for .NET." },
+ { "name": "Nerdbank.GitVersioning", "link": "https://github.com/dotnet/Nerdbank.GitVersioning", "description": "Version computation from git height." },
+ { "name": "PaperMod", "link": "https://github.com/adityatelange/hugo-PaperMod", "description": "Hugo theme." },
+ { "name": "Proxmox VE", "link": "https://www.proxmox.com/en/proxmox-virtual-environment/overview", "description": "Virtualization platform." },
+ { "name": "regex101.com", "link": "https://regex101.com/", "description": "Regular expression tester." },
+ { "name": "RFC 5646 language tags", "link": "https://www.rfc-editor.org/rfc/rfc5646.html", "description": "Language tag standard." },
+ { "name": "ruff", "link": "https://docs.astral.sh/ruff/", "description": "Python linter and formatter." },
+ { "name": "Serilog", "link": "https://serilog.net/", "description": "Structured logging library for .NET." },
+ { "name": "Utf8JsonAsyncStreamReader", "link": "https://github.com/gragra33/Utf8JsonAsyncStreamReader", "description": "Streaming JSON reader for .NET." },
+ { "name": "uv", "link": "https://docs.astral.sh/uv/", "description": "Python package and project manager." },
+ { "name": "Whisper", "link": "https://github.com/Softcatala/whisper-ctranslate2", "description": "Speech recognition transcriber." },
+ { "name": "Xml2CSharp", "link": "https://xmltocsharp.azurewebsites.net/", "description": "XML to C# class generator." },
+ { "name": "xUnit.Net", "link": "https://xunit.net/", "description": "Test framework for .NET." }
+ ]
+}
diff --git a/spec/third-party-tools.schema.json b/spec/third-party-tools.schema.json
new file mode 100644
index 00000000..23f72062
--- /dev/null
+++ b/spec/third-party-tools.schema.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/ptr727/ProjectTemplate/spec/third-party-tools.schema.json",
+ "title": "Shared 3rd party tool catalog",
+ "type": "object",
+ "required": ["tools"],
+ "additionalProperties": false,
+ "properties": {
+ "$schema": { "type": "string" },
+ "note": { "type": "string" },
+ "tools": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["name", "link", "description"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1, "description": "The tool's name as a README links it." },
+ "link": { "type": "string", "minLength": 1, "description": "The one URL every repository links this tool by." },
+ "description": { "type": "string", "minLength": 1, "description": "What the tool is, never what a repository does with it." }
+ }
+ }
+ }
+ }
+}
diff --git a/spec/validate.py b/spec/validate.py
index e15a4cf8..5e5c9b24 100644
--- a/spec/validate.py
+++ b/spec/validate.py
@@ -73,6 +73,63 @@ def check_secret_set(label, entry, need_kind):
errors.append(f"secrets.json: oidc {label} forbids no static credential")
check_secret_set("baseline", secrets.get("baseline"), need_kind=False)
+
+ # The README model is indexed directly by spec/audit.py, so a key that is absent or the wrong type crashes the audit mid-run rather than reporting.
+ # The schema marks each one required, but CI runs no JSON-schema validation, so the guard that actually runs is this one.
+ # Checked as a set rather than one key at a time, since guarding only the keys a review happened to name is how the other four came to be unguarded.
+ readme_model = load("spec/readme-sections.json")
+ if not isinstance(readme_model, dict):
+ errors.append("readme-sections.json: top level is not an object")
+ else:
+ for key, want in (("sections", list), ("shieldClasses", list), ("linkGroups", list),
+ ("linkNaming", list), ("canonicalLinks", list), ("distribution", dict)):
+ value = readme_model.get(key)
+ if not isinstance(value, want) or not value:
+ errors.append(f"readme-sections.json: '{key}' must be a non-empty {'array' if want is list else 'object'}, and spec/audit.py indexes it directly")
+ # The distribution prefixes are what tell a repo's own URLs from a third party's.
+ # Their absence fails open rather than loud: link_kind would classify every own URL as external, canonical naming would quietly stop being enforced, and the audit would still report green.
+ prefixes = readme_model.get("distribution", {}).get("urlPrefixes") if isinstance(readme_model.get("distribution"), dict) else None
+ if not is_str_list(prefixes) or not prefixes:
+ errors.append("readme-sections.json: 'distribution.urlPrefixes' must be a non-empty array of strings, or the link audit stops distinguishing this repo's URLs from a third party's and silently passes")
+ else:
+ # Every prefix, not merely one of them.
+ # A broad entry added beside a valid one would pass an any() guard while making link_kind read a third party's URL as this repo's own, which is the failure the guard exists to stop.
+ loose = [p for p in prefixes if "{slug}" not in p and "{owner}" not in p]
+ if loose:
+ errors.append(f"readme-sections.json: 'distribution.urlPrefixes' entry {loose[0]!r} carries neither {{slug}} nor {{owner}}, so it is not repo-scoped and would match another owner's URLs")
+ # A canonicalLinks entry naming a destination it cannot match is a name nothing enforces, and audit.py raises on it mid-run rather than reporting.
+ for c in readme_model.get("canonicalLinks", []) if isinstance(readme_model.get("canonicalLinks"), list) else []:
+ if isinstance(c, dict) and "repoPath" not in c and "match" not in c:
+ errors.append(f"readme-sections.json: canonicalLinks entry '{c.get('name')}' carries neither 'repoPath' nor 'match', so it can match no URL")
+
+ # CI runs no JSON-schema validation, so shape-check the shared tool catalog here.
+ # A duplicate name is the failure worth catching: the audit keys on it, so the second entry silently shadows the first and half the fleet is measured against a description nobody can see.
+ # The top level is read defensively rather than assumed: a malformed file (a bare array, say) would raise
+ # AttributeError off .get and crash the run, which is the opposite of what shape-checking here is for.
+ catalog = load("spec/third-party-tools.json")
+ if not isinstance(catalog, dict):
+ # One diagnostic, naming the outermost thing that is wrong.
+ # Reporting the missing 'tools' as well would describe a consequence of this as if it were a second defect.
+ errors.append("third-party-tools.json: top level is not an object")
+ elif not isinstance(catalog.get("tools"), list) or not catalog["tools"]:
+ errors.append("third-party-tools.json: 'tools' must be a non-empty array")
+ else:
+ tools = catalog["tools"]
+ seen = set()
+ for t in tools:
+ name = t.get("name") if isinstance(t, dict) else None
+ if not isinstance(t, dict) or not all(isinstance(t.get(f), str) and t.get(f) for f in ("name", "link", "description")):
+ errors.append(f"third-party-tools.json: entry {name or t!r} needs a non-empty name, link and description")
+ continue
+ if name.lower() in seen:
+ errors.append(f"third-party-tools.json: duplicate tool name '{name}' - the audit keys on it, so the second entry would shadow the first")
+ seen.add(name.lower())
+ desc = t["description"]
+ if not (desc[0].isupper() and desc.endswith(".")):
+ errors.append(f"third-party-tools.json: '{name}' description {desc!r} is not a sentence - open with a capital and close with a full stop")
+ names = [t["name"] for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)]
+ if names != sorted(names, key=str.lower):
+ errors.append("third-party-tools.json: 'tools' is not sorted by name, which is how a reader finds an entry to copy")
if not isinstance(mechanisms, dict):
errors.append("secrets.json: 'mechanisms' is not an object")
else: