Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CODESTYLE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
125 changes: 125 additions & 0 deletions spec/audit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.*)?$")


Expand DownExpand Up@@ -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"]:
Expand DownExpand Up@@ -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:
Expand DownExpand Up@@ -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.
Expand DownExpand Up@@ -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 <!-- omit from toc -->\n\nDoes widget things.\n\n## Build\n"
h_md = "# Widget\n\nDoes widget things.\n\n## Release History\n"
h_bad = "# Widget\n\nDoes other things.\n\n## Release History\n"
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

Expand Down
1 change: 1 addition & 0 deletions spec/project-types.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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" }
]
},
Expand Down
6 changes: 5 additions & 1 deletion spec/readme-structure.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ The preferred `README.md` shape for a fleet project. The audit's `readme-structu

## Sections and Order

1. **Title (`# <Name>`)** - the repo name, then a one-line description as the next paragraph.
1. **Title (`# <Name>`)** - the repo name, then a one-line description as the next paragraph. That line is **link-free plain text**: it doubles as the GitHub About description (AGENTS.md "Repository Details"), which renders no markdown, so a link would carry as raw brackets. The audit checks both properties.
2. **Build and Distribution (`##`)** - a bullet per distribution channel the project actually ships, each linking where it lives: **Source Code** (the GitHub repo), **Versioned Releases** (GitHub Releases), **Docker Images** (Docker Hub), **NuGet Packages** (NuGet.org), **PyPI Packages** (PyPI.org). List only the channels the project uses. It carries three sub-sections:
- **Build Status (`###`)** - the CI/build status shields (release build, Docker build, last commit, last build).
- **Releases (`###`)** - the version shields (GitHub release, GitHub pre-release, Docker latest/develop, NuGet, PyPI), one per channel the project publishes.
Expand DownExpand Up@@ -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 (`<!-- Shields -->`, `<!-- Workflow -->`, `<!-- Repo -->`, `<!-- External -->`) 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 `# <Title>` (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.