From 485657401d6f2e0d42064e472a05efea0797820c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 23 Jul 2026 08:11:26 -0700 Subject: [PATCH 1/7] Codify central MSBuild config for C# and the workspace-file editing rule Two gaps surfaced by the PhotoCleaner adoption (maintainer feedback): - CODESTYLE.md gains "Central Build and Package Configuration": Directory.Build.props carries the shared build/analyzer properties (the Zero Warnings set, LangVersion, uniform TargetFramework) and Directory.Packages.props enables central package management with versionless per-project PackageReference items. This is already the de-facto standard (PlexCleaner, Utilities, MediaTools, LanguageTags carry both) but was never written down, which is how PhotoCleaner, AudioCleaner, and NxWitness stayed on per-project config. spec/project-types.json gains the matching csharp.centralconfig.props letter check so the audit validates it. - AGENTS.md "Verification Discipline" gains the workspace-file rule: never edit an active .code-workspace file - a rewritten workspace file can make VS Code reload the window and destroy the running agent session's context, and the trigger is not fully characterized (an agent's edit has caused it where a human's identical edit did not). Surface the change for the maintainer instead. The section is verbatim-carried, so the rule propagates mechanically. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 1 + CODESTYLE.md | 9 +++++++++ spec/project-types.json | 1 + 3 files changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1ae694d0..99f462d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,7 @@ The checks that separate work actually done from work that merely reports succes - **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. The workflow instance of this is [`WORKFLOW.md`](./WORKFLOW.md) D8.4 (an identity allowlist used as a gate). - **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure - and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The invocations are in "Running the Linters Locally"; that section documents *how* to run each, this rule is that **all** of them run. - **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits (`splitlines(keepends=True)`) or literal replacement over regex reassembly. This is the mechanism behind the Line Endings warning above, and it is worth naming because the corruption is invisible in a rendered diff. +- **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context - the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand instead, or make it the very last staged action of a session. - **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. (The `changes`-job rule under "Branching Model" is this rule's instance for that one job.) - **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else - `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. - **A review flags an instance; fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample; they do not enumerate. diff --git a/CODESTYLE.md b/CODESTYLE.md index 10902ddc..d220655f 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -61,6 +61,15 @@ This is the style guide for any **.NET projects** in this repo. - CI runs the clean-compile checks on every PR as the authoritative backstop - Git hooks are optional; a repo may wire a local runner (Husky.Net) for pre-commit enforcement, but CI is the gate that matters +#### Central Build and Package Configuration + +Shared MSBuild configuration is centralized at the repository root, never duplicated per project: + +- **`Directory.Build.props`** carries the properties every project shares - the analyzer set and `TreatWarningsAsErrors` from the Zero Warnings Policy above, plus `LangVersion`, `TargetFramework` where uniform, and any repo-wide build metadata. A csproj carries only what is genuinely project-specific (`OutputType`, `IsPackable`, project references). +- **`Directory.Packages.props`** enables central package management (`ManagePackageVersionsCentrally` true): every dependency version is declared once as a `PackageVersion` item, and a csproj's `PackageReference` items are versionless. One file to review on a bump, one Dependabot surface, and no version skew between projects. + +A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` items is drifted - move the shared property or version up to the root file rather than editing it in place. + #### Build Tasks Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The three clean-compile tasks below are carried verbatim; a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: diff --git a/spec/project-types.json b/spec/project-types.json index 6df7f80d..22c97818 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -9,6 +9,7 @@ "checks": [ { "id": "csharp.editorconfig.ruleblock", "verdict": "letter", "assert": ".editorconfig carries the shared [*.cs] plus ReSharper rule block.", "intentRef": "CODESTYLE.md" }, { "id": "csharp.analyzers.zerowarnings", "verdict": "intent", "assert": "Analyzer severities are enforced; warnings are not relaxed or suppressed wholesale.", "intentRef": "CODESTYLE.md" }, + { "id": "csharp.centralconfig.props", "verdict": "letter", "assert": "Shared MSBuild configuration is centralized at the repo root: Directory.Build.props carries the common analyzer and warning properties (the Zero Warnings set), and Directory.Packages.props enables ManagePackageVersionsCentrally with every dependency version declared once - a csproj carries only project-specific properties and versionless PackageReference items.", "intentRef": "CODESTYLE.md" }, { "id": "csharp.coverage.codecov", "verdict": "letter", "assert": "The unit-test job collects coverage (dotnet test --collect:\"XPlat Code Coverage\" --results-directory ./coverage) and uploads it to Codecov via codecov/codecov-action, best-effort (fail_ci_if_error: false so a Codecov outage or an absent token never reds the gate); CODECOV_TOKEN is stored in the repo actions secrets and reaches the reusable validator via secrets: inherit. Required for every C# repo with tests.", "intentRef": "WORKFLOW.md" } ] }, From ede6037a759534ddc5ea8bf5ef50e7912d31f8aa Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 23 Jul 2026 08:18:27 -0700 Subject: [PATCH 2/7] Add the cspell-duplication and HISTORY-mirror audit checks, make the workspace rule absolute Maintainer feedback from driving the PhotoCleaner adoption, mechanized: - cspell single source of truth: when cspell.json is carried, a *.code-workspace that still carries a cSpell word list (words/userWords/ignoreWords, matched as quoted keys so a plain cspell.json mention is not a hit) is a LETTER - the exact silent-drift CODESTYLE.md "Markdown and Spelling" already forbids, now checked. An unreadable workspace is a DRIFT to verify by hand. - HISTORY.md mirrors the README opening: spec/readme-structure.md gains the rule (the changelog opens as the README's twin - same H1 title and intro paragraph), and the audit compares them with HTML comments stripped so the README's ToC-omit marker is not a false difference. Title mismatch and intro drift are LETTERs. Verified against the fleet convention (hub and PlexCleaner both conform byte-for-byte). - The AGENTS.md workspace-file rule is now a consistent absolute: never edit an active .code-workspace, surface the change for the maintainer - the last-staged-action escape hatch contradicted the "never" and is gone. Selftest covers both new helpers. Live: the hub self-audit stays clean, and PhotoCleaner surfaces both real findings (a stale workspace word list, a non-mirroring HISTORY intro). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- spec/audit.py | 83 ++++++++++++++++++++++++++++++++++++++++ spec/readme-structure.md | 4 ++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 99f462d5..3a602828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,7 +187,7 @@ The checks that separate work actually done from work that merely reports succes - **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. The workflow instance of this is [`WORKFLOW.md`](./WORKFLOW.md) D8.4 (an identity allowlist used as a gate). - **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure - and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The invocations are in "Running the Linters Locally"; that section documents *how* to run each, this rule is that **all** of them run. - **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits (`splitlines(keepends=True)`) or literal replacement over regex reassembly. This is the mechanism behind the Line Endings warning above, and it is worth naming because the corruption is invisible in a rendered diff. -- **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context - the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand instead, or make it the very last staged action of a session. +- **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context - the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. - **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. (The `changes`-job rule under "Branching Model" is this rule's instance for that one job.) - **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else - `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. - **A review flags an instance; fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample; they do not enumerate. diff --git a/spec/audit.py b/spec/audit.py index a1ef939d..e3f36174 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -183,6 +183,40 @@ def heading_texts(markdown): return {m.group(1).strip().lower() for line in markdown.splitlines() for m in (_HEADING.match(line),) if m} +_HTML_COMMENT = re.compile(r"", re.S) + + +def title_and_intro(text): + """The H1 title text and the intro region before the first H2, HTML comments stripped. + + Drives the README/HISTORY mirror check (spec/readme-structure.md "HISTORY.md"): both files open with the + same title and intro, and the README's ToC-omit comment must not read as a difference. + """ + norm = _HTML_COMMENT.sub("", normalize(text)) + title, intro, seen_h1 = None, [], False + for ln in norm.split("\n"): + s = ln.strip() + if not seen_h1: + if s.startswith("# "): + title = s[2:].strip() + seen_h1 = True + continue + if s.startswith("## "): + break + if s: + intro.append(s) + return title, "\n".join(intro) + + +def workspace_cspell_words(text): + """True if workspace/settings JSON carries its own cSpell word list - the block cspell.json canonicalizes. + + Matches the quoted setting keys case-insensitively, so a mere mention of the cspell.json file is not a hit. + """ + low = text.lower() + return any(key in low for key in ('"cspell.words"', '"cspell.userwords"', '"cspell.ignorewords"')) + + _JOB_KEY = re.compile(r"^([A-Za-z0-9_.\-]+):(\s.*)?$") @@ -526,6 +560,7 @@ def audit_repo(entry, spec): sel = repo_selectors(entry, spec["registry"].get("defaults", {})) wanted_sections = {} # path -> set of required section names, unioned across applicable entries verbatim_secs = {} # path -> set of section names checked byte-for-byte against the hub canonical + doc_texts = {} # README.md / HISTORY.md content, retained for the mirror check check_item = {} # path -> entry, for a fidelity interface/verbatim entry (last applicable wins per path) path_order = [] for item in spec["files"]["baseline"]: @@ -555,6 +590,8 @@ def audit_repo(entry, spec): # Guard on encoding, not truthiness: an empty file returns encoding "base64" with content "" (decode it # to ""), whereas a too-large or non-inline payload returns encoding "none" (text stays None -> flagged). text = base64.b64decode(content["content"]).decode("utf-8", "replace") if content.get("encoding") == "base64" else None + if path in ("README.md", "HISTORY.md") and text is not None: + doc_texts[path] = text # retained for the README/HISTORY mirror check below # Interface conformance (name + wiring) plus any verbatim job regions the contract pins. if item is not None and fid == "interface": if text is None: @@ -594,6 +631,34 @@ def audit_repo(entry, spec): findings.extend(check_verbatim(f"{path} section '{name}'", text, path, extract=lambda t, n=name: extract_section(t, n))) + # --- HISTORY.md mirrors the README opening --- + # spec/readme-structure.md "HISTORY.md": the changelog opens as the README's twin - same H1 title and the + # same intro paragraph. Checked only when both files were readable (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)")) + + # --- cspell single source of truth --- + # 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. Checked only when cspell.json is carried - its + # absence is already a file LETTER above, and a workspace list with no cspell.json is that same finding. + if gh(f"repos/{slug}/contents/cspell.json?ref={ground}", ok404=True) is not None: + root_entries = gh(f"repos/{slug}/contents/?ref={ground}", ok404=True) or [] + for it in root_entries: + ws_name = it.get("name", "") if isinstance(it, dict) else "" + if not ws_name.endswith(".code-workspace"): + continue + ws = gh(f"repos/{slug}/contents/{ws_name}?ref={ground}", ok404=True) + ws_text = base64.b64decode(ws["content"]).decode("utf-8", "replace") if ws and ws.get("encoding") == "base64" else None + if ws_text is None: + findings.append(("DRIFT", f"cspell: could not read {ws_name} on {ground} to check for a duplicated word list; verify by hand")) + elif workspace_cspell_words(ws_text): + findings.append(("LETTER", f"cspell: {ws_name} carries a cSpell word list while cspell.json is the single source of truth - delete the workspace copy (CODESTYLE.md Markdown and Spelling)")) + # --- Registry driftNotes freshness --- # Gated on everything else passing: a clean repo has no outstanding work for a pending-marker note to # describe. Narrow markers keep a permanent-deviation note ("relies on validate-task") from tripping. @@ -713,6 +778,24 @@ def _selftest(): else: print(" ok issue: render_issue groups must-fix/converge/unverifiable, counts findings, handles the clean case") + # README/HISTORY mirror: same title+intro matches modulo the ToC-omit comment, and intro drift is caught. + r_md = "# Widget \n\nDoes widget things.\n\n## Build\n" + h_md = "# Widget\n\nDoes widget things.\n\n## Release History\n" + h_bad = "# Widget\n\nDoes other things.\n\n## Release History\n" + if title_and_intro(r_md) != title_and_intro(h_md) or title_and_intro(r_md) == title_and_intro(h_bad): + ok = False + print(" FAIL history: README/HISTORY title+intro mirror detection") + else: + print(" ok history: title+intro mirror matches modulo the ToC-omit comment, intro drift detected") + # cspell duplication: a workspace cSpell word list is detected, and a mere cspell.json mention is not. + ws_dup = '{ "settings": { "cSpell.words": ["foo"] } }' + ws_ok = '{ "settings": { "editor.rulers": [100] }, "note": "words live in cspell.json" }' + if not workspace_cspell_words(ws_dup) or workspace_cspell_words(ws_ok): + ok = False + print(" FAIL cspell: workspace word-list detection") + else: + print(" ok cspell: workspace cSpell word list detected, a plain cspell.json mention is not") + print("SELFTEST PASS" if ok else "SELFTEST FAIL") return 0 if ok else 1 diff --git a/spec/readme-structure.md b/spec/readme-structure.md index 3bc48bdf..407d19e5 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -34,6 +34,10 @@ Shields are not a top-level section - they live under **Build and Distribution** - A project README describes only that project - 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. The auto-generated Table of Contents is the one exception, keeping inline anchor links. +## HISTORY.md + +`HISTORY.md` is the maintainer-curated changelog and opens as the README's twin: 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, and the audit checks that the title and intro match the README (HTML comments stripped). + ## Docker Hub README A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the Docker Hub repository 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. From 5a290277439238ef6ef481fe0eaca71ec73a9c89 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:23:44 -0700 Subject: [PATCH 3/7] Address Copilot round 2: the mirror check honors "copied verbatim" title_and_intro no longer drops blank lines inside the intro region - the surrounding blanks are trimmed but the interior structure is kept, so a paragraph-boundary difference is a real difference, matching the spec's "copied verbatim". Selftest gains the paragraph-boundary case. Live re-verified: hub self-audit clean, PhotoCleaner still flags its non-mirroring intro. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- spec/audit.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index e3f36174..877292b6 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -193,7 +193,7 @@ def title_and_intro(text): same title and intro, and the README's ToC-omit comment must not read as a difference. """ norm = _HTML_COMMENT.sub("", normalize(text)) - title, intro, seen_h1 = None, [], False + title, region, seen_h1 = None, [], False for ln in norm.split("\n"): s = ln.strip() if not seen_h1: @@ -203,9 +203,14 @@ def title_and_intro(text): continue if s.startswith("## "): break - if s: - intro.append(s) - return title, "\n".join(intro) + region.append(ln.rstrip()) + # Trim the blank lines surrounding the region but keep the interior ones, so a paragraph-boundary + # difference is a real difference - the spec says the intro is copied verbatim. + while region and not region[0]: + region.pop(0) + while region and not region[-1]: + region.pop() + return title, "\n".join(region) def workspace_cspell_words(text): @@ -782,11 +787,14 @@ def _selftest(): r_md = "# Widget <!-- omit from toc -->\n\nDoes widget things.\n\n## Build\n" h_md = "# Widget\n\nDoes widget things.\n\n## Release History\n" h_bad = "# Widget\n\nDoes other things.\n\n## Release History\n" - if title_and_intro(r_md) != title_and_intro(h_md) or title_and_intro(r_md) == title_and_intro(h_bad): + r_two = "# W\n\nLine one.\n\nLine two.\n\n## Build\n" + h_joined = "# W\n\nLine one.\nLine two.\n\n## Release History\n" + if (title_and_intro(r_md) != title_and_intro(h_md) or title_and_intro(r_md) == title_and_intro(h_bad) + or title_and_intro(r_two) == title_and_intro(h_joined)): ok = False print(" FAIL history: README/HISTORY title+intro mirror detection") else: - print(" ok history: title+intro mirror matches modulo the ToC-omit comment, intro drift detected") + print(" ok history: mirror matches modulo the ToC-omit comment, intro drift and paragraph-boundary drift detected") # cspell duplication: a workspace cSpell word list is detected, and a mere cspell.json mention is not. ws_dup = '{ "settings": { "cSpell.words": ["foo"] } }' ws_ok = '{ "settings": { "editor.rulers": [100] }, "note": "words live in cspell.json" }' From 602bac16ad729afa76baec3eaf1a55748a4e8ca1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:27:35 -0700 Subject: [PATCH 4/7] Add the About-description mirror check AGENTS.md "Repository Details" already fixes the convention - the About description is the README's first line after the H1 as plain text, links stripped, README as source of truth - but nothing checked it. The audit now compares the live description against the link-stripped README intro line (LETTER on mismatch, with both strings quoted and the sharpen-the-README escape from the convention), and separately flags an intro line that carries markdown links - spec/readme-structure.md now requires it link-free, since the unrendered description would carry raw brackets. Selftest covers the link stripping. Live: the hub and PhotoCleaner both conform (their About matches their intro line), so no new fleet findings from this check yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- spec/audit.py | 31 +++++++++++++++++++++++++++++++ spec/readme-structure.md | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/spec/audit.py b/spec/audit.py index 877292b6..af9bb54d 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -184,6 +184,16 @@ def heading_texts(markdown): _HTML_COMMENT = re.compile(r"<!--.*?-->", re.S) +_MD_LINK_INLINE = re.compile(r"\[([^\]]*)\]\([^)]*\)") +_MD_LINK_REF = re.compile(r"\[([^\]]*)\]\[[^\]]*\]") + + +def strip_md_links(text): + """Markdown links reduced to their text - `[text](url)` and `[text][ref]` become `text`. + + The plain-text form AGENTS.md "Repository Details" says the About description carries. + """ + return _MD_LINK_REF.sub(r"\1", _MD_LINK_INLINE.sub(r"\1", text)) def title_and_intro(text): @@ -647,6 +657,20 @@ def audit_repo(entry, spec): 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)")) + # --- Repository description mirrors the README intro line --- + # AGENTS.md "Repository Details": the About description is the README's first line after the H1 as plain + # text (links stripped), and the README is the source of truth. spec/readme-structure.md additionally wants + # that line link-free, so it carries to the unrendered description without formatting loss. + if "README.md" in doc_texts: + intro_line = title_and_intro(doc_texts["README.md"])[1].split("\n")[0] + if intro_line: + 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)")) + desc = (live.get("description") or "").strip() + want = strip_md_links(intro_line).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 (AGENTS.md Repository Details)")) + # --- cspell single source of truth --- # 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. Checked only when cspell.json is carried - its @@ -795,6 +819,13 @@ def _selftest(): print(" FAIL history: README/HISTORY title+intro mirror detection") else: print(" ok history: mirror matches modulo the ToC-omit comment, intro drift and paragraph-boundary drift detected") + # Description mirror: links reduce to their text, and a link-free line passes through unchanged. + linked = "Utility to clean [media](https://x.example) per the [spec][spec-ref]." + if strip_md_links(linked) != "Utility to clean media per the spec." or strip_md_links("Plain intro line.") != "Plain intro line.": + ok = False + print(" FAIL description: strip_md_links behavior") + else: + print(" ok description: markdown links reduce to their text, plain text passes through") # cspell duplication: a workspace cSpell word list is detected, and a mere cspell.json mention is not. ws_dup = '{ "settings": { "cSpell.words": ["foo"] } }' ws_ok = '{ "settings": { "editor.rulers": [100] }, "note": "words live in cspell.json" }' diff --git a/spec/readme-structure.md b/spec/readme-structure.md index 407d19e5..c106811a 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -4,7 +4,7 @@ The preferred `README.md` shape for a fleet project. The audit's `readme-structu ## Sections and Order -1. **Title (`# <Name>`)** - the repo name, then a one-line description as the next paragraph. +1. **Title (`# <Name>`)** - the repo name, then a one-line description as the next paragraph. That line is **link-free plain text**: it doubles as the GitHub About description (AGENTS.md "Repository Details"), which renders no markdown, so a link would carry as raw brackets. The audit checks both properties. 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. From c168bc9fb981dd39d632c749f2252480ed4bffe3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:28:17 -0700 Subject: [PATCH 5/7] Address Copilot round 3: isinstance-guard the workspace contents payload The contents API returns a list for a directory, so a *.code-workspace path that resolves to one would make ws.get raise and abort the audit. Guard on isinstance dict, degrading to the existing unreadable-workspace DRIFT. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- spec/audit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/audit.py b/spec/audit.py index af9bb54d..35d2102f 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -682,7 +682,8 @@ def audit_repo(entry, spec): if not ws_name.endswith(".code-workspace"): continue ws = gh(f"repos/{slug}/contents/{ws_name}?ref={ground}", ok404=True) - ws_text = base64.b64decode(ws["content"]).decode("utf-8", "replace") if ws and ws.get("encoding") == "base64" else None + # isinstance guard: the contents API returns a list for a directory, and .get would raise on it. + ws_text = base64.b64decode(ws["content"]).decode("utf-8", "replace") if isinstance(ws, dict) and ws.get("encoding") == "base64" else None if ws_text is None: findings.append(("DRIFT", f"cspell: could not read {ws_name} on {ground} to check for a duplicated word list; verify by hand")) elif workspace_cspell_words(ws_text): From 2d4148082d5b349c97d7a7e9c0ddc0ebd843c38c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:36:27 -0700 Subject: [PATCH 6/7] Address Copilot round 4: strip inline links whose URL carries parentheses The inline-link pattern now accepts one level of balanced parentheses in the URL (the Wikipedia-style case), so strip_md_links reduces such a link to its text instead of leaving residue. Selftest case updated to a parenthesized URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- spec/audit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 35d2102f..88ad9fba 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -184,7 +184,7 @@ def heading_texts(markdown): _HTML_COMMENT = re.compile(r"<!--.*?-->", re.S) -_MD_LINK_INLINE = re.compile(r"\[([^\]]*)\]\([^)]*\)") +_MD_LINK_INLINE = re.compile(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)") # URL may hold one level of () _MD_LINK_REF = re.compile(r"\[([^\]]*)\]\[[^\]]*\]") @@ -821,7 +821,7 @@ def _selftest(): else: print(" ok history: mirror matches modulo the ToC-omit comment, intro drift and paragraph-boundary drift detected") # Description mirror: links reduce to their text, and a link-free line passes through unchanged. - linked = "Utility to clean [media](https://x.example) per the [spec][spec-ref]." + linked = "Utility to clean [media](https://x.example/Foo_(bar)) per the [spec][spec-ref]." if strip_md_links(linked) != "Utility to clean media per the spec." or strip_md_links("Plain intro line.") != "Plain intro line.": ok = False print(" FAIL description: strip_md_links behavior") From 56b733f346dcfebb9164ec076593c8b73f684b40 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen <ptr727@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:39:56 -0700 Subject: [PATCH 7/7] Address Copilot round 5: a missing README intro line is a finding, not a skip spec/readme-structure.md requires the title-then-one-line-description opening, so a README with no intro paragraph now raises a LETTER instead of silently skipping the link-free and About-description checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- spec/audit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/audit.py b/spec/audit.py index 88ad9fba..bf47e2b3 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -663,7 +663,9 @@ def audit_repo(entry, spec): # that line link-free, so it carries to the unrendered description without formatting loss. if "README.md" in doc_texts: intro_line = title_and_intro(doc_texts["README.md"])[1].split("\n")[0] - if intro_line: + 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)")) + 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)")) desc = (live.get("description") or "").strip()