diff --git a/AGENTS.md b/AGENTS.md index 1ae694d0..3a602828 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. - **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/audit.py b/spec/audit.py index a1ef939d..bf47e2b3 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -183,6 +183,55 @@ 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) +_MD_LINK_INLINE = re.compile(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)") # URL may hold one level of () +_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): + """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, region, 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 + 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): + """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 +575,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 +605,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 +646,51 @@ 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)")) + + # --- 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 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() + 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 + # 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) + # 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): + 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 +810,34 @@ 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" + 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: 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/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") + 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" }' + 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/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" } ] }, diff --git a/spec/readme-structure.md b/spec/readme-structure.md index 3bc48bdf..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 (`# `)** - the repo name, then a one-line description as the next paragraph. +1. **Title (`# `)** - 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. @@ -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.