From b1fec3e6f40d8e6993f88db672024f04cccca0db Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 22 Aug 2026 11:11:51 -0700 Subject: [PATCH 1/5] Declare the Repository Description in registry/repos.json (#639) (#913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Implements TODO.md's "The Declared Repository Description" cluster, the prerequisite #639 itself named as blocking it. `registry/repos.json` gains an optional per-repo `description` field: - **`registry/repos.schema.json`**: new optional `description` (string, 1-100 chars). - **`spec/validate.py`**: shape-checks it the same way as `lineEndings`/`workflowModel`. - **`repo-config/configure.sh`**: `apply` writes the declared description to the GitHub About panel; `check` asserts it when declared, and notes a manual-verify fallback otherwise. - **`spec/audit.py`**: the description-check block is extracted into a new `description_findings()` (now unit-tested directly rather than only through the full `audit_repo` integration path). Once a repo declares the field, it becomes canonical and the README tagline, the About panel, and the Docker Hub short description are all measured against it. A repo with no declared field keeps the exact prior behavior (README is the source, About/Docker Hub measured against the tagline). - **`GOVERNANCE.md`** "Repository Details" and **`spec/readme-structure.md`** are updated to describe the new precedence. - **ProjectTemplate adopts the field here as the pilot** (its README tagline, live About description, and the new declared value all already agree). - The completed TODO.md cluster is removed per the file's own "delete completed entries" convention. ## Why This closes the README→About hop #639 reported as the one mirror nothing writes for any repo that adopts the field: once declared, `configure.sh apply` is the tool that sets the About panel, rather than a hand copy. The field is optional so the fleet adopts it one repo at a time, exactly as TODO.md's "Settled" notes describe. ## Testing - `python3 spec/audit.py --selftest` — passes, including 8 new cases for `description_findings()`. - `python3 spec/validate.py` — passes against the updated registry. - `python3 -m unittest discover -s scripts/tests -p "test_*.py"` — 765 tests, all pass. - `python3 scripts/prose_lint.py` (whole tree) — clean. - `ruff format --check` / `ruff check` on the touched Python files — clean. - `bash -n repo-config/configure.sh` — clean; manually verified the new conditional-echo substitution doesn't trip `set -Eeuo pipefail` in either branch. Fixes #639 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit - **New Features** - Repository descriptions can be centrally defined and applied to GitHub repository settings. - README taglines and external descriptions now follow the declared repository description when available. - Optional descriptions support validated plain text up to 100 characters. - **Bug Fixes** - Audits now detect missing, inconsistent, invalid, or outdated descriptions across README files, GitHub, and Docker Hub. - **Documentation** - Updated description guidance and added a description for ProjectTemplate. - Removed completed repository-description tasks. --- GOVERNANCE.md | 2 +- TODO.md | 18 -- registry/repos.json | 1 + registry/repos.schema.json | 7 + repo-config/configure.sh | 40 +++- scripts/tests/test_spec_validate.py | 52 +++++ spec/audit.py | 297 ++++++++++++++++++++-------- spec/readme-structure.md | 4 +- spec/validate.py | 35 ++++ 9 files changed, 355 insertions(+), 101 deletions(-) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 75edfc1e..e170a8c4 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -269,7 +269,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 **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. +- **Description** is one canonical sentence that carries to the README, the About panel, and (for a Docker repo) the Docker Hub short description alike, at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the source, itself link-free plain text on one line for the same reason: `repo-config/configure.sh apply` writes it to the About panel directly, and the README's **tagline** (its first non-empty line after the `#` H1 heading) follows it rather than the other way around. A repo that has not adopted the field yet keeps the pre-existing convention, where the README tagline is the source of truth and the About panel is set from it by hand (`gh api -X PATCH repos// -f description=...`). `spec/audit.py`'s `description_findings()` reports drift either way, falling back to the tagline when no field is declared. 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. When the current description is *more specific* than the declared source (a chip revision or variant it omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the declared source so the other mirrors follow it. 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 canonical value 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/TODO.md b/TODO.md index 0ea1cb91..4e4d3159 100644 --- a/TODO.md +++ b/TODO.md @@ -35,23 +35,6 @@ One pull request pointing a hub `uses:` at a hub-owned action, so that the resol - **Settled** - The resolvability pass reports what it covered on every run, so the hub's zero is visible rather than silent, which is why this is a separate decision rather than a defect in that pass. - **Settled** - The fleet's `ptr727` pins are live in the downstream repos that consume the action, and `repo_gate.py --root ` from a hub checkout reads them there, so the pass is not idle fleet-wide. -### The Declared Repository Description - -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], 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. - - **Issue** - None filed, and the disposition is recorded on [#509][issue-509]. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where neither `registry/repos.json` nor `registry/repos.schema.json` carries a `description` key. - - **Open** - Nothing beyond sequencing, which is that this leads and the README shape follows. - - **Settled** - PhotoCleaner#32 measures the cost of parsing, since a workflow step reading the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. - - **Settled** - The field makes the README intro a third mirror rather than the source, so the audit compares all three against one declared value and `repo-config/configure.sh` sets the About panel from the same field it already sets every other setting from. - - **Settled** - The 100-character cap stays, since Docker Hub's short description is the tightest surface. - - **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. - ### Reducing the Carried Surface Further One pull request measuring the remaining carried surface against the carry-versus-reach test and moving whatever qualifies, now that the model is settled rather than open. @@ -515,7 +498,6 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [issue-456]: https://github.com/ptr727/ProjectTemplate/issues/456 [issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 [issue-489]: https://github.com/ptr727/ProjectTemplate/issues/489 -[issue-509]: https://github.com/ptr727/ProjectTemplate/issues/509 [issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521 [issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523 [issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 diff --git a/registry/repos.json b/registry/repos.json index 9347dfbb..d3ea07aa 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -10,6 +10,7 @@ { "name": "ProjectTemplate", "url": "https://github.com/ptr727/ProjectTemplate", + "description": "Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails.", "status": "cataloged", "types": ["source-only", "docs"], "groundTruthBranch": "main", diff --git a/registry/repos.schema.json b/registry/repos.schema.json index be6df3a5..4efe29be 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -47,6 +47,13 @@ "properties": { "name": { "type": "string", "minLength": 1 }, "url": { "type": "string", "format": "uri", "minLength": 1 }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^\\S(?:[^\\n\\r]*\\S)?$", + "$comment": "Single line, no leading/trailing whitespace - kept in sync with spec/validate.py's description_errors(). That function also rejects Markdown links, which this pattern does not attempt to express." + }, "status": { "enum": ["cataloged", "backlog", "archived", "excluded"] }, "exclusionReason": { "type": "string", "minLength": 1, "pattern": "\\S" }, "types": { "type": "array", "items": { "type": "string" } }, diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 2972e361..e5de18eb 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -64,6 +64,29 @@ esac main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" +# ----- Resolve the declared description (optional, shared by apply and check) ----- +# Per GOVERNANCE.md "Repository Details", once a repo declares registry/repos.json's `description` field, that field becomes the About panel's source rather than the README. +# The audit's description_findings() (spec/audit.py) measures the README, About, and Docker Hub mirror set against that same field. +# A repo with no declared field is left untouched here, so the README stays its source of truth. +description="" +if [ -f "$registry" ]; then + # Trimmed defensively even though spec/validate.py already rejects an untrimmed value. + # A registry edited ahead of its next validate.py run still resolves to the same canonical value spec/audit.py compares against. + if ! description="$(jq -r --arg n "$name" \ + '(.repos[] | select(.name==$n) | .description) // "" | gsub("^\\s+|\\s+$"; "")' "$registry")"; then + echo "Failed to read description from $registry (invalid JSON?)." >&2 + exit 1 + fi + # The trim above only strips leading/trailing whitespace, so an embedded newline or carriage return survives it. + # Caught here rather than left to reach `gh api` as a multi-line value. + case "$description" in + *$'\n'* | *$'\r'*) + echo "The declared description for $name in $registry carries an embedded newline. Fix it there (spec/validate.py rejects this once run)." >&2 + exit 1 + ;; + esac +fi + # ----- Ruleset id lookup (shared by apply and check) ----- # Map a ruleset name to the id of the first match, leaving it empty when nothing matches. # It warns on duplicates, and aborts on an API error or at the per_page cap, where a single-fetch lookup is unreliable. @@ -158,7 +181,12 @@ cmd_apply() { payload="$(jq --argjson d "$disc" '. + {has_discussions: $d}' "$settings_file")" echo "Warning: $repo has no 'main' branch. Leaving default_branch unchanged." >&2 fi - echo "Applying general settings (has_discussions=$disc)" + # The About description, only once a repo declares registry/repos.json's `description` (see the resolution above). + # Left untouched otherwise, so a repo that has not adopted the field yet keeps its hand-set (or README-derived) description. + if [ -n "$description" ]; then + payload="$(jq --arg desc "$description" '. + {description: $desc}' <<<"$payload")" + fi + echo "Applying general settings (has_discussions=$disc$([ -n "$description" ] && echo ", description from registry/repos.json"))" printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null # ----- Dependabot alerts + automated security updates ----- gh api --method PUT "repos/$repo/vulnerability-alerts" >/dev/null @@ -275,6 +303,16 @@ check_settings() { if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then assert "default_branch = main" test "$(jq -r '.default_branch' <<<"$live")" = main fi + # The About description, only where the registry declares one (see the resolution above). + # A repo that has not adopted the field is a manual-verify note, exactly as secrets are: nothing declared here to check against. + # The two reasons `$description` can be empty are told apart, since "no registry" and "no field for this repo" call for different follow-up. + if [ -n "$description" ]; then + assert "description = '$description'" test "$(jq -r '.description' <<<"$live")" = "$description" + elif [ ! -f "$registry" ]; then + note "description: no $registry to read (pass a plain repo argument or run from a hub checkout) - verify manually" + else + note "description: no registry/repos.json description declared for $name - verify manually (falls back to the README tagline, see GOVERNANCE.md 'Repository Details')" + fi } check_security() { diff --git a/scripts/tests/test_spec_validate.py b/scripts/tests/test_spec_validate.py index 1fe70977..c7a29340 100755 --- a/scripts/tests/test_spec_validate.py +++ b/scripts/tests/test_spec_validate.py @@ -142,5 +142,57 @@ def test_checks_an_explicit_whole_intent_file_that_also_lists_sections(self) -> self.assertEqual(1, len(validate.carried_link_errors(self.root, baseline))) +class DescriptionErrorsCase(unittest.TestCase): + """registry/repos.json's optional `description` (GOVERNANCE.md "Repository Details").""" + + def test_a_plain_short_sentence_is_clean(self) -> None: + self.assertEqual(validate.description_errors("Fixture", "A short tagline."), []) + + def test_whitespace_only_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", " "), + ["Fixture: description must be a non-empty string"], + ) + + def test_an_inline_markdown_link_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "See [docs](https://example.test) for more."), + ["Fixture: description carries Markdown links - keep it link-free plain text"], + ) + + def test_a_reference_style_markdown_link_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "See [docs][ref] for more."), + ["Fixture: description carries Markdown links - keep it link-free plain text"], + ) + + def test_leading_or_trailing_whitespace_is_rejected(self) -> None: + # Not silently trimmed here, even though spec/audit.py and configure.sh both strip it defensively. + # Rejecting it at the source keeps the registry's own text the exact canonical form every mirror carries. + self.assertEqual( + validate.description_errors("Fixture", " A short tagline. "), + [ + "Fixture: description must be plain single-line text with no leading or trailing whitespace" + ], + ) + + def test_an_embedded_newline_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "A tagline.\nA second line."), + [ + "Fixture: description must be plain single-line text with no leading or trailing whitespace" + ], + ) + + def test_exactly_the_cap_is_clean(self) -> None: + self.assertEqual(validate.description_errors("Fixture", "a" * 100), []) + + def test_over_the_cap_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "a" * 101), + ["Fixture: description is 101 characters, over the 100-char limit"], + ) + + if __name__ == "__main__": unittest.main() diff --git a/spec/audit.py b/spec/audit.py index 7006ba2e..e5f2bc47 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -1285,6 +1285,118 @@ def readme_shield_findings(text, model, entry): return findings +def description_findings(doc_texts, entry, live, slug): + """The README title/tagline, the GitHub About description, and the Docker Hub short description, checked as one mirror set. + + Per spec/readme-structure.md item 1 and GOVERNANCE.md "Repository Details", the H1 is the repo name. 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. + + registry/repos.json's optional `description` is the canonical value once a repo declares it (GOVERNANCE.md + "Repository Details"). repo-config/configure.sh then writes the About panel from that same field. A repo that + has not adopted it yet still has the README as its source of truth, so the checks below fall back to the + tagline exactly as before. + + A declared field's mirrors (About, Docker Hub) are measured against it even where the README itself could + not be read (missing, oversized, or fetched with no inline content) or carries no usable tagline, since the + declared value is canonical on its own and does not need the README to establish it. + """ + findings = [] + declared = (entry.get("description") or "").strip() or None + readme_want = None + if "README.md" in doc_texts: + title, intro = title_and_intro(doc_texts["README.md"]) + 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 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 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 tagline carries Markdown links - keep it link-free plain text, it doubles as the repo About description (spec/readme-structure.md)", + ) + ) + readme_want = strip_md_links(intro_line).strip() + if len(readme_want) > 100: + findings.append( + ( + "LETTER", + f"readme: the tagline is {len(readme_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)", + ) + ) + # The declared field wins once a repo has one; every mirror (README, About, Docker Hub) is then measured against it. + # A repo with no declared field keeps the README as the source, exactly as before, so with no readable tagline + # there is nothing yet to measure the other mirrors against. + want = declared or readme_want + if want is None: + return findings + source = "registry/repos.json" if declared else "the README" + if declared and readme_want is not None and readme_want != declared: + findings.append( + ( + "LETTER", + f"readme: the tagline ('{readme_want}') does not match the declared description ('{declared}') - registry/repos.json's description is canonical once declared, so the README follows it (GOVERNANCE.md Repository Details)", + ) + ) + desc = (live.get("description") or "").strip() + if desc != want: + findings.append( + ( + "LETTER", + f"description: the About description does not match the {'declared description' if declared else 'README tagline'} (description '{desc}' vs '{want}') - set it from {source}, or sharpen {source} first if the description carries real detail (GOVERNANCE.md Repository Details)", + ) + ) + # Docker Hub short description mirrors the same canonical value, 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. + if any( + (pt.get("target") if isinstance(pt, dict) else pt) == "docker" + for pt in entry.get("publish", []) + ): + try: + dh = docker_hub_description(slug) + except Exception as e: # noqa: BLE001 + dh = None + findings.append( + ( + "DRIFT", + f"description: could not read the Docker Hub short description to verify it mirrors {source} ({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 '{want}' - set it from {source} (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. @@ -2197,85 +2309,8 @@ def audit_repo(entry, spec, branch=None): ) # --- 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 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 = 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 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 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 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 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 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. - if any( - (pt.get("target") if isinstance(pt, dict) else pt) == "docker" - for pt in entry.get("publish", []) - ): - try: - dh = docker_hub_description(slug) - except Exception as e: # noqa: BLE001 - 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 tagline ('{want}') - set it from the README (spec/readme-structure.md)", - ) - ) + # See description_findings() for the mirror set (README, GitHub About, Docker Hub) and the declared-field precedence. + findings += description_findings(doc_texts, entry, live, slug) # --- 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. @@ -4120,6 +4155,110 @@ def _selftest(): else: print(" ok tagline: the first line of the intro region, further paragraphs excluded") + # description_findings(): the README/About/Docker Hub mirror set, with and without a declared registry field. + desc_readme = {"README.md": "# Fixture\n\nA short tagline.\n"} + desc_cases = [ + ( + "no declared field, About matches the README tagline", + desc_readme, + {}, + {"description": "A short tagline."}, + 0, + ), + ( + "no declared field, About mismatches the README tagline", + desc_readme, + {}, + {"description": "Something else."}, + 1, + ), + ( + "declared field matches both README and About", + desc_readme, + {"description": "A short tagline."}, + {"description": "A short tagline."}, + 0, + ), + ( + "declared field present, README tagline diverges from it", + desc_readme, + {"description": "The declared description."}, + {"description": "The declared description."}, + 1, + ), + ( + "declared field present, About diverges from it", + desc_readme, + {"description": "A short tagline."}, + {"description": "Something else."}, + 1, + ), + ( + "declared field present, no README at all, About diverges from it", + {}, + {"description": "The declared description."}, + {"description": "Something else."}, + 1, + ), + ( + "declared field present, no README at all, About already matches it", + {}, + {"description": "The declared description."}, + {"description": "The declared description."}, + 0, + ), + ( + "no declared field and no README, nothing to measure against", + {}, + {}, + {"description": "Anything."}, + 0, + ), + ] + for label, doc_texts_fx, entry_fx, live_fx, wantn in desc_cases: + got = description_findings(doc_texts_fx, entry_fx, live_fx, "owner/Fixture") + if len(got) != wantn: + ok = False + print( + f" {'ok ' if len(got) == wantn else 'FAIL'} want={wantn} got={len(got)} description: {label}" + ) + if len(got) != wantn: + for _, t in got: + print(f" {t}") + # The declared field, once present, is what the wording names as the source - not "the README". + declared_mismatch = description_findings( + desc_readme, + {"description": "The declared description."}, + {"description": "Something else."}, + "owner/Fixture", + ) + if not any( + "declared description" in t and "registry/repos.json" in t for _, t in declared_mismatch + ): + ok = False + print(f" FAIL description: declared-field wording -> {declared_mismatch}") + else: + print( + " ok description: a declared field names registry/repos.json as the source, not the README" + ) + # A docker-publishing repo's Docker Hub short description is checked against the same canonical value. + real_dhd = globals()["docker_hub_description"] + globals()["docker_hub_description"] = lambda slug: "A stale Docker Hub blurb." + try: + docker_mismatch = description_findings( + desc_readme, + {"description": "A short tagline.", "publish": [{"target": "docker"}]}, + {"description": "A short tagline."}, + "owner/Fixture", + ) + finally: + globals()["docker_hub_description"] = real_dhd + if not any("Docker Hub short description" in t for _, t in docker_mismatch): + ok = False + print(f" FAIL description: Docker Hub mismatch not reported -> {docker_mismatch}") + else: + print(" ok description: a docker repo's stale Docker Hub short description is reported") + # 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. diff --git a/spec/readme-structure.md b/spec/readme-structure.md index babfc7c8..fcaaa32e 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -6,7 +6,7 @@ The order, the requirement, and the shield sets are declared data in [`readme-se ## 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 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. +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 is one canonical short description shared with the GitHub About description (GOVERNANCE.md "Repository Details"), the `HISTORY.md` opening, and, for a repo that publishes a Docker image, 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. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the canonical value and the tagline follows it. A repo that has not adopted the field keeps the tagline itself as the source. **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. @@ -97,7 +97,7 @@ The rule lives in [`CODESTYLE.md`][codestyle] "Markdown and Spelling", which eve 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. +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. `repo-config/configure.sh apply` writes the About panel from `registry/repos.json`'s declared `description` once a repo has one, so that hop is a tool call rather than a hand edit for an adopting repo. A repo that has not declared the field still closes it by hand. Where About has drifted from the canonical value, the publish faithfully carries the drift onward, which is why the audit checks all three surfaces rather than only the two CI touches. diff --git a/spec/validate.py b/spec/validate.py index 446f144e..70648088 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -42,6 +42,10 @@ MARKDOWN_INLINE_LINK = re.compile(r"\]\((?P[^)\s]+)") MARKDOWN_REFERENCE_LINK = re.compile(r"^\[[^]]+\]:\s*(?P\S+)", re.MULTILINE) TEMPLATE_REPOSITORY_URL = "https://github.com/ptr727/ProjectTemplate" +# A description-shaped link use, `[text](url)` or `[text][ref]`, kept in sync with spec/audit.py's strip_md_links(). +# The carried-link regexes above find a definition's target inside a whole document, not a use inside one short string. +DESCRIPTION_LINK_INLINE = re.compile(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)") +DESCRIPTION_LINK_REF = re.compile(r"\[([^\]]*)\]\[[^\]]*\]") def load(rel): @@ -52,6 +56,33 @@ def is_str_list(v): return isinstance(v, list) and all(isinstance(x, str) for x in v) +def description_errors(name, desc): + """Shape errors for a registry entry's optional `description` (GOVERNANCE.md "Repository Details"). + + Absence is not checked here, since the field is optional. `desc` is only passed in once a repo declares it. + + Link-free is enforced because spec/audit.py's description_findings() strips Markdown links from the README's + own tagline before comparing, but never re-strips the declared field it compares that tagline against. A + declared value carrying a link would therefore report as a permanent readme mismatch, and repo-config/ + configure.sh would push the literal Markdown source to GitHub's About panel, which does not render it. + + Already trimmed and single-line is enforced so the registry's own text already reads as exactly what every + mirror carries, rather than relying on the whitespace-stripping spec/audit.py and repo-config/configure.sh + each do defensively to keep agreeing with each other. + """ + if not isinstance(desc, str) or not desc.strip(): + return [f"{name}: description must be a non-empty string"] + if desc != desc.strip() or "\n" in desc or "\r" in desc: + return [ + f"{name}: description must be plain single-line text with no leading or trailing whitespace" + ] + if DESCRIPTION_LINK_INLINE.search(desc) or DESCRIPTION_LINK_REF.search(desc): + return [f"{name}: description carries Markdown links - keep it link-free plain text"] + if len(desc) > 100: + return [f"{name}: description is {len(desc)} characters, over the 100-char limit"] + return [] + + def markdown_targets(text): """Yield link targets outside fenced blocks.""" visible = [] @@ -471,6 +502,10 @@ def check_secret_set(label, entry, need_kind): effective_model = model or default_model or "release" if effective_model == "operational" and eol is None: errors.append(f"{name}: operational repo must declare lineEndings (lf or crlf)") + # Optional per GOVERNANCE.md "Repository Details": a repo that has not adopted the field yet is unaffected, since spec/audit.py's description_findings() falls back to the README tagline for it. + desc = repo.get("description") + if desc is not None: + errors.extend(description_errors(name, desc)) status = repo.get("status") if status is None: From f17351c5a22da70e110a015808cf46c8bb795a36 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 22 Aug 2026 11:55:16 -0700 Subject: [PATCH 2/5] Guard load_repo_tools Against an Empty install.linux Set (#916) (#917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `host-setup/linux/install-tools.sh --repo PATH` died on a `host-tools.json` that declares tools but zero `install.linux` entries, instead of treating "nothing to add" as a no-op. ## Root cause In `load_repo_tools()`, when no `.tools[]` entry has `.install.linux`, the `jq` filter produces no output, so `rows` is the empty string. `<<< "$rows"` still feeds the `while read` loop one line (a here-string always appends a trailing newline, even for an empty string), so the loop ran once with `name`/`manager`/`package` all empty and immediately died on the "non-empty tool name" check — even though the declaration was otherwise valid and simply had nothing to add. ## Fix Guard the loop on an empty `$rows` before entering it, per the issue's suggested fix. ## Testing - Repro from the issue now reports normally instead of dying. - Verified the normal path (repo `host-tools.json` with a real `install.linux` entry) still works. - `python3 -m unittest scripts.tests.test_host_gate` — 83 tests pass. - `scripts/repo_gate.py` and `scripts/prose_lint.py` — clean. Fixes #916 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved Linux tool setup handling when no repository-specific tools are available. * Avoided unnecessary processing during installation in this scenario. Co-authored-by: Claude Sonnet 5 --- host-setup/linux/install-tools.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/host-setup/linux/install-tools.sh b/host-setup/linux/install-tools.sh index 4e488570..076ce7dc 100755 --- a/host-setup/linux/install-tools.sh +++ b/host-setup/linux/install-tools.sh @@ -1200,6 +1200,7 @@ load_repo_tools() { end ' "$declaration") || die "Cannot read constrained Linux install metadata from $declaration" + [[ -n $rows ]] || return 0 while IFS=$'\t' read -r name manager package; do [[ -n $name ]] || die "$declaration carries Linux install metadata without a non-empty tool name" if [[ $manager != "apt" || ! $package =~ ^[a-z0-9][a-z0-9+.-]*$ ]]; then From abfeb987a89f5f9cc06de0a532e5374f0df8977b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 22 Aug 2026 12:43:35 -0700 Subject: [PATCH 3/5] Fix Review Findings From the Develop-to-Main Promotion PR (#915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes real bugs and trims comments flagged in #914's review round (Copilot/CodeRabbit/qodo), since develop is PR-gated and the promotion PR's head can't take a direct push. ## Real fixes - `configure.sh`: the leading/trailing trim used `gsub("^\\s+|\\s+$"; "")`, and Oniguruma's `\s` matches `\n`/`\r`, so an embedded newline sitting at either edge was silently stripped instead of tripping the newline guard right after it. Trims only space/tab now. - `configure.sh`: the missing-registry manual-verify note told a user to "pass a plain repo argument", but the registry path is resolved from `script_dir` regardless of that argument. Reworded. - `spec/validate.py`: tests `"description" in repo` rather than `is not None`, so an explicit `"description": null` is rejected as invalid instead of reading as absent. - `spec/audit.py`: `description_findings()` no longer crashes on a non-string declared description - reports a `DEFECT` and treats it as undeclared. - Regression tests added for all four. ## Style fixes Trimmed three `configure.sh` comment blocks that restated GOVERNANCE.md prose across 2-3 lines, per `comment-and-doc-style/SKILL.md` ("one line is the default", "no rule citations - governed lives in the fleet's own instruction set") - a doc I hadn't checked in PR #913's earlier review rounds. ## Declined (reasoning posted to #914) - Registry description not validated as "one sentence" - the README-derived tagline never was either, pre-existing gap. - Sentence-length on `spec/readme-structure.md` - the same opt-in rule is violated in 29+ pre-existing spots in that file. - PR title's lowercase "to" - it's an allowed bind word per the actual list, a known qodo false positive. - Spaced hyphen in `spec/readme-structure.md` - the `- **Label** - explanation` shape is an explicit documented carve-out. - Docstring "wraps mid-sentence" - matches two other unchanged functions' docstrings in the same file, the established convention. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved description validation for missing, null, non-string, empty, whitespace-only, and newline-containing values. * Absent descriptions remain valid, while explicitly declared invalid values are reported clearly. * Duplicate entries are now detected, and whitespace is preserved for accurate validation. * Updated messages clarify when descriptions are declared and applied. * **Tests** * Added coverage for null, non-string, invalid registry, duplicate, and absent descriptions. --- repo-config/configure.sh | 62 +++++++++++++++-------- scripts/tests/test_spec_validate.py | 22 ++++++++ spec/audit.py | 78 ++++++++++++++++++++++++++++- spec/validate.py | 20 ++++++-- 4 files changed, 156 insertions(+), 26 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index e5de18eb..4995ad59 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -65,26 +65,47 @@ main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" # ----- Resolve the declared description (optional, shared by apply and check) ----- -# Per GOVERNANCE.md "Repository Details", once a repo declares registry/repos.json's `description` field, that field becomes the About panel's source rather than the README. -# The audit's description_findings() (spec/audit.py) measures the README, About, and Docker Hub mirror set against that same field. -# A repo with no declared field is left untouched here, so the README stays its source of truth. +# Absence keeps the About panel following the README. description="" if [ -f "$registry" ]; then - # Trimmed defensively even though spec/validate.py already rejects an untrimmed value. - # A registry edited ahead of its next validate.py run still resolves to the same canonical value spec/audit.py compares against. - if ! description="$(jq -r --arg n "$name" \ - '(.repos[] | select(.name==$n) | .description) // "" | gsub("^\\s+|\\s+$"; "")' "$registry")"; then - echo "Failed to read description from $registry (invalid JSON?)." >&2 + # Fails loud on a duplicate name (already a validate.py DEFECT) rather than picking one entry over the other. + if ! match_count="$(jq -r --arg n "$name" '[.repos[] | select(.name==$n)] | length' "$registry")"; then + echo "Failed to read $registry (invalid JSON?)." >&2 exit 1 fi - # The trim above only strips leading/trailing whitespace, so an embedded newline or carriage return survives it. - # Caught here rather than left to reach `gh api` as a multi-line value. - case "$description" in - *$'\n'* | *$'\r'*) - echo "The declared description for $name in $registry carries an embedded newline. Fix it there (spec/validate.py rejects this once run)." >&2 + if [ "$match_count" -gt 1 ]; then + echo "$match_count registry entries named $name in $registry. Resolve the duplicate before its description can be read (spec/validate.py rejects this once run)." >&2 + exit 1 + fi + if ! declared="$(jq -r --arg n "$name" '.repos[] | select(.name==$n) | has("description")' "$registry")"; then + echo "Failed to read $registry (invalid JSON?)." >&2 + exit 1 + fi + if [ "$declared" = "true" ]; then + # Exactly one match is already established above, so select() itself yields exactly one value here. + # No trim: this only ever validates the value against spec/validate.py's contract, never normalizes it. + # A non-string value (including an explicit null) resolves to empty here, caught by the same guard. + # -j plus the trailing sentinel keeps command substitution from stripping a genuine trailing newline. + if ! description="$(jq -j --arg n "$name" \ + '(.repos[] | select(.name==$n) | .description) | if type == "string" then . else empty end' \ + "$registry" && printf x)"; then + echo "Failed to read description from $registry (invalid JSON?)." >&2 exit 1 - ;; - esac + fi + description="${description%x}" + case "$description" in + "" | [[:space:]]* | *[[:space:]]) + echo "The declared description for $name in $registry is not a non-empty string with no leading or trailing whitespace. Fix it there (spec/validate.py rejects this once run)." >&2 + exit 1 + ;; + esac + case "$description" in + *$'\n'* | *$'\r'*) + echo "The declared description for $name in $registry carries an embedded newline. Fix it there (spec/validate.py rejects this once run)." >&2 + exit 1 + ;; + esac + fi fi # ----- Ruleset id lookup (shared by apply and check) ----- @@ -181,8 +202,7 @@ cmd_apply() { payload="$(jq --argjson d "$disc" '. + {has_discussions: $d}' "$settings_file")" echo "Warning: $repo has no 'main' branch. Leaving default_branch unchanged." >&2 fi - # The About description, only once a repo declares registry/repos.json's `description` (see the resolution above). - # Left untouched otherwise, so a repo that has not adopted the field yet keeps its hand-set (or README-derived) description. + # Applies only once a repo declares the field (see the resolution above). if [ -n "$description" ]; then payload="$(jq --arg desc "$description" '. + {description: $desc}' <<<"$payload")" fi @@ -303,15 +323,13 @@ check_settings() { if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then assert "default_branch = main" test "$(jq -r '.default_branch' <<<"$live")" = main fi - # The About description, only where the registry declares one (see the resolution above). - # A repo that has not adopted the field is a manual-verify note, exactly as secrets are: nothing declared here to check against. - # The two reasons `$description` can be empty are told apart, since "no registry" and "no field for this repo" call for different follow-up. + # $description is empty for two different reasons, told apart below since each needs different follow-up. if [ -n "$description" ]; then assert "description = '$description'" test "$(jq -r '.description' <<<"$live")" = "$description" elif [ ! -f "$registry" ]; then - note "description: no $registry to read (pass a plain repo argument or run from a hub checkout) - verify manually" + note "description: no $registry to read (it resolves relative to this script, not from the repo argument). Run from a hub checkout for it to exist, and verify manually." else - note "description: no registry/repos.json description declared for $name - verify manually (falls back to the README tagline, see GOVERNANCE.md 'Repository Details')" + note "description: no matching registry entry or no declared description key for $name (falls back to the README tagline, see GOVERNANCE.md 'Repository Details'). Verify manually." fi } diff --git a/scripts/tests/test_spec_validate.py b/scripts/tests/test_spec_validate.py index c7a29340..44da6a01 100755 --- a/scripts/tests/test_spec_validate.py +++ b/scripts/tests/test_spec_validate.py @@ -154,6 +154,28 @@ def test_whitespace_only_is_rejected(self) -> None: ["Fixture: description must be a non-empty string"], ) + def test_an_explicit_null_is_rejected_rather_than_read_as_absent(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", None), + ["Fixture: description must be a non-empty string"], + ) + + def test_for_repo_an_absent_key_produces_no_errors(self) -> None: + self.assertEqual(validate.description_errors_for_repo({}, "Fixture"), []) + + def test_for_repo_an_explicit_null_is_rejected_rather_than_read_as_absent(self) -> None: + # Locks in the presence-vs-None guard: this regresses to `[]` if it is ever weakened back to `is not None`. + self.assertEqual( + validate.description_errors_for_repo({"description": None}, "Fixture"), + ["Fixture: description must be a non-empty string"], + ) + + def test_a_non_string_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", 42), + ["Fixture: description must be a non-empty string"], + ) + def test_an_inline_markdown_link_is_rejected(self) -> None: self.assertEqual( validate.description_errors("Fixture", "See [docs](https://example.test) for more."), diff --git a/spec/audit.py b/spec/audit.py index e5f2bc47..ee0b8650 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -41,6 +41,8 @@ from datetime import UTC, datetime from typing import Any +import validate # sibling, import-safe (its main is guarded) + ROOT = pathlib.Path(__file__).resolve().parent.parent SETTINGS_KEYS = [ @@ -1303,7 +1305,21 @@ def description_findings(doc_texts, entry, live, slug): declared value is canonical on its own and does not need the README to establish it. """ findings = [] - declared = (entry.get("description") or "").strip() or None + declared = None + if "description" in entry: + # Delegates to validate.py's own contract instead of re-checking a second, easily-incomplete copy of it + # (an earlier version here missed the link and length rules, accepting either as canonical). + shape_errors = validate.description_errors("registry", entry["description"]) + if shape_errors: + findings += [ + ( + "DEFECT", + f"{msg}. Treated as undeclared here, since it fails spec/validate.py's contract.", + ) + for msg in shape_errors + ] + else: + declared = entry["description"] readme_want = None if "README.md" in doc_texts: title, intro = title_and_intro(doc_texts["README.md"]) @@ -4214,6 +4230,55 @@ def _selftest(): {"description": "Anything."}, 0, ), + ( + "a non-string declared field is reported rather than crashing", + desc_readme, + {"description": 42}, + {"description": "A short tagline."}, + 1, + ), + ( + "an explicit null declared field is reported rather than read as absent", + desc_readme, + {"description": None}, + {"description": "A short tagline."}, + 1, + ), + ( + "a whitespace-only declared field is reported rather than read as absent", + desc_readme, + {"description": " "}, + {"description": "A short tagline."}, + 1, + ), + ( + "a padded declared field is a DEFECT rather than silently trimmed", + desc_readme, + {"description": " A short tagline. "}, + {"description": "A short tagline."}, + 1, + ), + ( + "a declared field with an embedded newline is a DEFECT rather than silently accepted", + desc_readme, + {"description": "A short tagline.\nA second line."}, + {"description": "A short tagline."}, + 1, + ), + ( + "a declared field carrying a Markdown link is a DEFECT rather than silently canonical", + desc_readme, + {"description": "See [docs](https://example.test) for more."}, + {"description": "A short tagline."}, + 1, + ), + ( + "a declared field over the 100-char cap is a DEFECT rather than silently canonical", + desc_readme, + {"description": "a" * 101}, + {"description": "A short tagline."}, + 1, + ), ] for label, doc_texts_fx, entry_fx, live_fx, wantn in desc_cases: got = description_findings(doc_texts_fx, entry_fx, live_fx, "owner/Fixture") @@ -4225,6 +4290,17 @@ def _selftest(): if len(got) != wantn: for _, t in got: print(f" {t}") + # A null declared field is a DEFECT via validate.py's own contract, not a silent LETTER-only "About mismatch". + null_declared = description_findings( + desc_readme, {"description": None}, {"description": "A short tagline."}, "owner/Fixture" + ) + if not any( + k == "DEFECT" and "description must be a non-empty string" in t for k, t in null_declared + ): + ok = False + print(f" FAIL description: null-declared-field DEFECT contract -> {null_declared}") + else: + print(" ok description: a null declared field is a DEFECT via validate.py's contract") # The declared field, once present, is what the wording names as the source - not "the README". declared_mismatch = description_findings( desc_readme, diff --git a/spec/validate.py b/spec/validate.py index 70648088..d686dd5b 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -56,6 +56,17 @@ def is_str_list(v): return isinstance(v, list) and all(isinstance(x, str) for x in v) +def description_errors_for_repo(repo, name): + """The per-repo optional-field guard: an explicit `"description": null` is declared-but-invalid, not absent. + + Presence (`"description" in repo`) is the test, not `repo.get("description") is not None`, so a `null` reaches + description_errors() rather than being read as though the field were never declared. + """ + if "description" not in repo: + return [] + return description_errors(name, repo["description"]) + + def description_errors(name, desc): """Shape errors for a registry entry's optional `description` (GOVERNANCE.md "Repository Details"). @@ -461,6 +472,7 @@ def check_secret_set(label, entry, need_kind): ) seen_identities = set() + seen_names = set() for i, repo in enumerate(repos["repos"]): if not isinstance(repo, dict): errors.append(f"repo #{i} is not an object") @@ -468,9 +480,13 @@ def check_secret_set(label, entry, need_kind): name = repo.get("name", f"#{i}") # This name only labels every error message below. # The membership check (spec/audit.py's membership_findings()) keys by owner/repo instead, parsed from url the same way this loop does. + # A duplicate is still an error, though, since repo-config/configure.sh and spec/audit.py's own per-repo entry lookup both key off it. if not isinstance(repo.get("name"), str) or not repo["name"].strip(): errors.append(f"repo #{i}: missing or empty 'name'") continue + if name in seen_names: + errors.append(f"{name}: duplicate registry entry for name '{name}'") + seen_names.add(name) if not isinstance(repo.get("url"), str) or not repo["url"].strip(): errors.append(f"{name}: missing or empty 'url'") continue @@ -503,9 +519,7 @@ def check_secret_set(label, entry, need_kind): if effective_model == "operational" and eol is None: errors.append(f"{name}: operational repo must declare lineEndings (lf or crlf)") # Optional per GOVERNANCE.md "Repository Details": a repo that has not adopted the field yet is unaffected, since spec/audit.py's description_findings() falls back to the README tagline for it. - desc = repo.get("description") - if desc is not None: - errors.extend(description_errors(name, desc)) + errors.extend(description_errors_for_repo(repo, name)) status = repo.get("status") if status is None: From 6b4929dac97119921fedad4fc71b388f1f07bca7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 22 Aug 2026 13:42:41 -0700 Subject: [PATCH 4/5] Delegate configure.sh's Description Check to Python Too (#918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes real gaps from PR #914's fresh review round (against the full accumulated diff, after #915 merged). - `configure.sh` never checked the declared description for Markdown links or the 100-char cap - a value `spec/audit.py` would flag as a DEFECT could still reach GitHub's About panel. Rather than a fourth partial hand-rolled copy of the rules, `configure.sh` now shells out to a new `spec/resolve_description.py`, which calls `spec/validate.py`'s `description_errors()` directly - the same function `spec/audit.py` already delegates to. This also removes the sentinel-preservation trick from three commits ago, since a value that passes the check can never contain a newline. - `spec/audit.py`'s DEFECT message for an invalid declared description named the literal string "registry" instead of the actual repo, making it useless in a fleet-wide run. Now passes the repo slug. - `spec/validate.py` now rejects a padded registry `name` (both `configure.sh` and `audit.py` use it as an exact-match lookup key). Declined two findings with reasoning posted to #914 (a pervasive pre-existing sentence-length violation, and a type-annotation request against a repo explicitly configured for the lint-only Scripts profile). Manually verified against 11 description shapes end to end via `spec/resolve_description.py` directly. 777 automated tests, prose lint, ruff, pyright, and mypy all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved repository description validation with clearer identification of affected repositories. * Rejected repository names with leading or trailing whitespace. * Improved handling of duplicate, malformed, or incomplete registry entries. * **Improvements** * Registry descriptions are now resolved more reliably, with clear validation errors and consistent output. * Repositories without descriptions are handled cleanly without unnecessary output. * Configuration now provides clearer errors when a compatible Python 3 interpreter is unavailable. --- repo-config/configure.sh | 46 +++--------- scripts/tests/test_resolve_description.py | 63 ++++++++++++++++ spec/audit.py | 8 +- spec/resolve_description.py | 92 +++++++++++++++++++++++ spec/validate.py | 5 ++ 5 files changed, 179 insertions(+), 35 deletions(-) create mode 100755 scripts/tests/test_resolve_description.py create mode 100755 spec/resolve_description.py diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 4995ad59..5016f373 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -68,44 +68,22 @@ settings_file="$script_dir/settings.json" # Absence keeps the About panel following the README. description="" if [ -f "$registry" ]; then - # Fails loud on a duplicate name (already a validate.py DEFECT) rather than picking one entry over the other. - if ! match_count="$(jq -r --arg n "$name" '[.repos[] | select(.name==$n)] | length' "$registry")"; then - echo "Failed to read $registry (invalid JSON?)." >&2 - exit 1 - fi - if [ "$match_count" -gt 1 ]; then - echo "$match_count registry entries named $name in $registry. Resolve the duplicate before its description can be read (spec/validate.py rejects this once run)." >&2 + # Resolved here, not near the top, so a run with no registry (an explicit model, no hub checkout) never needs Python at all. + # The name python3 is not universal: native Windows can register a Microsoft Store stub under that name that resolves on PATH but fails when actually run, so this runs it rather than just checking PATH (docs/host-setup.md). + # The probe itself is spec/resolve_description.py's actual floor (PEP 563, Python 3.7+) rather than an arbitrary version number, so a too-old interpreter fails here with a clear message instead of a bare SyntaxError from the script. + if python3 -c "from __future__ import annotations" >/dev/null 2>&1; then + py_cmd=(python3) + elif py -3 -c "from __future__ import annotations" >/dev/null 2>&1; then + py_cmd=(py -3) + else + echo "No Python 3.7+ interpreter found (python3 or py -3). See docs/host-setup.md." >&2 exit 1 fi - if ! declared="$(jq -r --arg n "$name" '.repos[] | select(.name==$n) | has("description")' "$registry")"; then - echo "Failed to read $registry (invalid JSON?)." >&2 + # Delegates to spec/resolve_description.py rather than a third hand-rolled copy of description_errors(). + # A description that passes that check can never contain a newline, so command substitution has nothing to strip. + if ! description="$("${py_cmd[@]}" "$script_dir/../spec/resolve_description.py" "$registry" "$name")"; then exit 1 fi - if [ "$declared" = "true" ]; then - # Exactly one match is already established above, so select() itself yields exactly one value here. - # No trim: this only ever validates the value against spec/validate.py's contract, never normalizes it. - # A non-string value (including an explicit null) resolves to empty here, caught by the same guard. - # -j plus the trailing sentinel keeps command substitution from stripping a genuine trailing newline. - if ! description="$(jq -j --arg n "$name" \ - '(.repos[] | select(.name==$n) | .description) | if type == "string" then . else empty end' \ - "$registry" && printf x)"; then - echo "Failed to read description from $registry (invalid JSON?)." >&2 - exit 1 - fi - description="${description%x}" - case "$description" in - "" | [[:space:]]* | *[[:space:]]) - echo "The declared description for $name in $registry is not a non-empty string with no leading or trailing whitespace. Fix it there (spec/validate.py rejects this once run)." >&2 - exit 1 - ;; - esac - case "$description" in - *$'\n'* | *$'\r'*) - echo "The declared description for $name in $registry carries an embedded newline. Fix it there (spec/validate.py rejects this once run)." >&2 - exit 1 - ;; - esac - fi fi # ----- Ruleset id lookup (shared by apply and check) ----- diff --git a/scripts/tests/test_resolve_description.py b/scripts/tests/test_resolve_description.py new file mode 100755 index 00000000..4be26443 --- /dev/null +++ b/scripts/tests/test_resolve_description.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Exercise resolve_description()'s registry-shape and fail-loud guards directly.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "spec")) +import resolve_description + + +class ResolveDescriptionCase(unittest.TestCase): + """repo-config/configure.sh's declared-description resolution (spec/resolve_description.py).""" + + def test_a_repo_with_no_declared_description_resolves_to_none(self) -> None: + registry = {"repos": [{"name": "Fixture"}]} + self.assertIsNone(resolve_description.resolve_description(registry, "Fixture")) + + def test_a_repo_absent_from_the_registry_resolves_to_none(self) -> None: + registry = {"repos": [{"name": "Other"}]} + self.assertIsNone(resolve_description.resolve_description(registry, "Fixture")) + + def test_a_valid_declared_description_is_returned(self) -> None: + registry = {"repos": [{"name": "Fixture", "description": "A short tagline."}]} + self.assertEqual( + resolve_description.resolve_description(registry, "Fixture"), "A short tagline." + ) + + def test_a_duplicate_name_raises_rather_than_picking_one(self) -> None: + registry = { + "repos": [ + {"name": "Fixture", "description": "First."}, + {"name": "Fixture", "description": "Second."}, + ] + } + with self.assertRaises(resolve_description.ResolveError): + resolve_description.resolve_description(registry, "Fixture") + + def test_an_invalid_declared_description_raises(self) -> None: + registry = {"repos": [{"name": "Fixture", "description": None}]} + with self.assertRaises(resolve_description.ResolveError): + resolve_description.resolve_description(registry, "Fixture") + + def test_a_registry_with_no_repos_array_raises_rather_than_reading_as_no_match(self) -> None: + with self.assertRaises(resolve_description.ResolveError): + resolve_description.resolve_description({}, "Fixture") + + def test_a_repos_value_that_is_not_a_list_raises(self) -> None: + with self.assertRaises(resolve_description.ResolveError): + resolve_description.resolve_description({"repos": "not-a-list"}, "Fixture") + + def test_a_padded_name_that_would_otherwise_match_raises_rather_than_reading_as_absent( + self, + ) -> None: + registry = {"repos": [{"name": " Fixture ", "description": "A short tagline."}]} + with self.assertRaises(resolve_description.ResolveError): + resolve_description.resolve_description(registry, "Fixture") + + +if __name__ == "__main__": + unittest.main() diff --git a/spec/audit.py b/spec/audit.py index ee0b8650..b7831303 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -1309,7 +1309,7 @@ def description_findings(doc_texts, entry, live, slug): if "description" in entry: # Delegates to validate.py's own contract instead of re-checking a second, easily-incomplete copy of it # (an earlier version here missed the link and length rules, accepting either as canonical). - shape_errors = validate.description_errors("registry", entry["description"]) + shape_errors = validate.description_errors(slug, entry["description"]) if shape_errors: findings += [ ( @@ -4301,6 +4301,12 @@ def _selftest(): print(f" FAIL description: null-declared-field DEFECT contract -> {null_declared}") else: print(" ok description: a null declared field is a DEFECT via validate.py's contract") + # The DEFECT names the actual repo, not a generic "registry" label, so it stays actionable in a fleet-wide run. + if not any(k == "DEFECT" and t.startswith("owner/Fixture:") for k, t in null_declared): + ok = False + print(f" FAIL description: DEFECT does not name the repo -> {null_declared}") + else: + print(" ok description: a declared-field DEFECT names the repo, not a generic label") # The declared field, once present, is what the wording names as the source - not "the README". declared_mismatch = description_findings( desc_readme, diff --git a/spec/resolve_description.py b/spec/resolve_description.py new file mode 100755 index 00000000..aaefd38c --- /dev/null +++ b/spec/resolve_description.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Resolve one repo's declared registry/repos.json description, for repo-config/configure.sh. + +Delegates to description_errors() (validate.py) so configure.sh validates a declared description +against the exact same contract spec/audit.py's description_findings() does, rather than a third +hand-rolled copy of the same rules. + +Prints the declared description to stdout and exits 0 when the repo has no declared description +(nothing printed) or exactly one valid one. Exits 1 with a message on stderr for anything +configure.sh should fail loud on: a malformed registry, more than one entry named NAME, or a +declared description that description_errors() rejects. + +Usage: resolve_description.py REGISTRY_PATH NAME +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import validate # sibling, import-safe (its main is guarded) + + +class ResolveError(Exception): + """A condition resolve_description() must fail loud on.""" + + +def resolve_description(registry: dict, name: str) -> str | None: + """The declared description for NAME in REGISTRY, or None if the repo has none declared. + + Raises ResolveError for anything the caller should fail loud on rather than silently read as + absent: a registry that is not an object carrying a `repos` array, an entry whose own name + would match NAME but for leading/trailing whitespace (spec/validate.py rejects that shape too, + so it is never the intended way to spell a mismatch), more than one entry named NAME, or a + declared description description_errors() rejects. + """ + if not isinstance(registry, dict) or not isinstance(registry.get("repos"), list): + raise ResolveError("registry is not an object with a 'repos' array") + repos = registry["repos"] + near_miss = next( + ( + r["name"] + for r in repos + if isinstance(r, dict) + and isinstance(r.get("name"), str) + and r["name"] != name + and r["name"].strip() == name + ), + None, + ) + if near_miss is not None: + raise ResolveError( + f"a registry entry's name {near_miss!r} carries leading/trailing whitespace" + ) + matches = [r for r in repos if isinstance(r, dict) and r.get("name") == name] + if len(matches) > 1: + raise ResolveError( + f"{len(matches)} registry entries named {name}. " + "Resolve the duplicate before its description can be read" + ) + if not matches or "description" not in matches[0]: + return None + desc = matches[0]["description"] + errors = validate.description_errors(name, desc) + if errors: + raise ResolveError("; ".join(errors)) + return desc + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: resolve_description.py REGISTRY_PATH NAME", file=sys.stderr) + return 1 + registry_path, name = sys.argv[1], sys.argv[2] + try: + registry = json.loads(Path(registry_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + print(f"Failed to read {registry_path}: {e}", file=sys.stderr) + return 1 + try: + desc = resolve_description(registry, name) + except ResolveError as e: + print(f"{e} (spec/validate.py rejects this once run).", file=sys.stderr) + return 1 + if desc is not None: + sys.stdout.write(desc) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spec/validate.py b/spec/validate.py index d686dd5b..83185e7b 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -484,6 +484,11 @@ def check_secret_set(label, entry, need_kind): if not isinstance(repo.get("name"), str) or not repo["name"].strip(): errors.append(f"repo #{i}: missing or empty 'name'") continue + if name != name.strip(): + # Both configure.sh and audit.py key their per-repo lookup off an exact match on name. + # A padded value would therefore make the entry unresolvable there, not merely cosmetic here. + errors.append(f"repo #{i}: name '{name}' carries leading/trailing whitespace") + continue if name in seen_names: errors.append(f"{name}: duplicate registry entry for name '{name}'") seen_names.add(name) From f726c7ab342b43318e64ec847b4f51e6e315874b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 22 Aug 2026 14:00:29 -0700 Subject: [PATCH 5/5] Reject a Case-Only Registry Name Mismatch Too (#919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a real gap from PR #914's fresh review round after #918 merged, plus declines two twins of findings already resolved on #918. - `spec/resolve_description.py`: extended the padded-name near-miss check (from #918) to also catch a case-only mismatch (e.g., resolving `Fixture` against a registry entry named `fixture`) - GitHub treats repo names case-insensitively, so this is a data-entry typo, not a different repo. Same mechanism, now normalizing with `.strip().casefold()` on both sides. New test case. Declined (reasoning posted to #914): - "Preserve interpreter probe failures" - identical to a finding already declined on #918, citing this file's own `gh_ok()` precedent for discarding a capability probe's output. - "Reject malformed repository entries" (a `null` in `repos`) - that's `spec/validate.py`'s whole-registry structural check; an unrelated malformed entry degrading to "nothing declared" for a different, valid repo's lookup is a safe outcome with a much wider blast radius than the specific near-miss cases already fixed. 786 automated tests, prose lint, ruff, pyright, and mypy all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved repository name matching to detect differences in capitalization and surrounding whitespace. * Name collisions are now reported correctly instead of being treated as missing repositories. * **Tests** * Added coverage for case-only repository name mismatches. --- scripts/tests/test_resolve_description.py | 5 +++++ spec/resolve_description.py | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/tests/test_resolve_description.py b/scripts/tests/test_resolve_description.py index 4be26443..43b8e544 100755 --- a/scripts/tests/test_resolve_description.py +++ b/scripts/tests/test_resolve_description.py @@ -58,6 +58,11 @@ def test_a_padded_name_that_would_otherwise_match_raises_rather_than_reading_as_ with self.assertRaises(resolve_description.ResolveError): resolve_description.resolve_description(registry, "Fixture") + def test_a_case_only_name_mismatch_raises_rather_than_reading_as_absent(self) -> None: + registry = {"repos": [{"name": "fixture", "description": "A short tagline."}]} + with self.assertRaises(resolve_description.ResolveError): + resolve_description.resolve_description(registry, "Fixture") + if __name__ == "__main__": unittest.main() diff --git a/spec/resolve_description.py b/spec/resolve_description.py index aaefd38c..c9b75615 100755 --- a/spec/resolve_description.py +++ b/spec/resolve_description.py @@ -31,13 +31,16 @@ def resolve_description(registry: dict, name: str) -> str | None: Raises ResolveError for anything the caller should fail loud on rather than silently read as absent: a registry that is not an object carrying a `repos` array, an entry whose own name - would match NAME but for leading/trailing whitespace (spec/validate.py rejects that shape too, - so it is never the intended way to spell a mismatch), more than one entry named NAME, or a - declared description description_errors() rejects. + would match NAME once whitespace and case differences are normalized away but not otherwise + (spec/validate.py rejects a padded name outright, and a GitHub repo name is compared + case-insensitively by GitHub itself, so a same-name-different-case entry is a data-entry + mistake rather than a different repo), more than one entry named NAME, or a declared + description that description_errors() rejects. """ if not isinstance(registry, dict) or not isinstance(registry.get("repos"), list): raise ResolveError("registry is not an object with a 'repos' array") repos = registry["repos"] + normalized_name = name.strip().casefold() near_miss = next( ( r["name"] @@ -45,13 +48,13 @@ def resolve_description(registry: dict, name: str) -> str | None: if isinstance(r, dict) and isinstance(r.get("name"), str) and r["name"] != name - and r["name"].strip() == name + and r["name"].strip().casefold() == normalized_name ), None, ) if near_miss is not None: raise ResolveError( - f"a registry entry's name {near_miss!r} carries leading/trailing whitespace" + f"a registry entry's name {near_miss!r} differs from {name!r} only by whitespace or letter case" ) matches = [r for r in repos if isinstance(r, dict) and r.get("name") == name] if len(matches) > 1: