diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c10fb7..8d35580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Each release moves `plugin.json`'s `version`, the flake package version and ever together. A consumer adopts one by bumping its flake input, its workflow `@v…`/`devkit-ref` and its marketplace `ref` at once. +## v0.12.0 — 2026-10-15 + +- `docs-lint` rule `f` checks the ADR shape the umbrella's docs build requires: `docs/adr/NNNN-.md`, + an `# ADR-NNNN: ` heading with the file's number, a `| **Status** |` row, and no hand-written + `index.md` or `README.md`. A consumer bump can newly fail its gate (#124). + ## v0.11.1 — 2026-10-15 - `bot-token` sets the bot's git identity with `GIT_AUTHOR_*`/`GIT_COMMITTER_*` variables instead of diff --git a/README.md b/README.md index efb142d..2810c10 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,8 @@ or a diff; `ruleset-sync apply ` creates or updates each, `--dry non-fork repo of an org (`--include-forks` adds forks). `just rulesets-check`/`just rulesets-apply` wrap both. **Docs lint** — `docs-lint [repo-root]` runs MIP-0074 §7's rules over `README.md` and -`docs/**/*.md`, skipping dated records (`docs/MIPs/**`, `docs/benchmarks/**`, `SPLIT.md`); a repo +`docs/**/*.md`, skipping dated records (`docs/MIPs/**`, `docs/benchmarks/**`, `SPLIT.md`), and checks each +`docs/adr/*.md` for the umbrella's ADR shape (rule `f`); a repo adds it to its quality recipe once its README is the landing. **The umbrella** — tools that read MIPs or `.tasks.md` look in the repo, then `../` (an umbrella diff --git a/scripts/docs_lint.py b/scripts/docs_lint.py index b7da8f0..64e39a1 100755 --- a/scripts/docs_lint.py +++ b/scripts/docs_lint.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stale-content check over README.md and docs/**/*.md (MIP-0074 §7, rules a–e). +"""Stale-content check over README.md and docs/**/*.md (MIP-0074 §7, rules a–f). docs-lint [repo-root] docs-lint --self-test @@ -137,6 +137,37 @@ def lint_file(text: str, rel: Path, known: set[str], owned: set[str], umbrella: KEPT_RECORDS = {("docs", "MIPs"), ("docs", "benchmarks")} +# The umbrella's prepare-docs fails its build on these (doc_links.py `adr_index`); check them before merge. +# Trap: the umbrella skips ADRs matched by its mkdocs `exclude_docs`; this checks every one. +ADR_FILE = re.compile(r"(\d{4})-[^/]+\.md") +ADR_STATUS = re.compile(r"^\|\s*\*\*Status\*\*\s*\|\s*(.*?)\s*\|\s*$", re.M) + + +def adr_findings(root: Path) -> list[tuple[str, int, str, str]]: + adr = root / "docs" / "adr" + out = [] + + def add(name: str, msg: str) -> None: + out.append((f"docs/adr/{name}", 1, "f", msg)) + + for name in ("index.md", "README.md"): + if (adr / name).exists(): + add(name, "hand-written; prepare-docs generates adr/index.md") + for f in sorted(adr.glob("*.md")): + if f.name in ("index.md", "README.md"): + continue + m = ADR_FILE.fullmatch(f.name) + if not m: + add(f.name, "not NNNN-.md") + continue + text = f.read_text(encoding="utf-8") + if not re.search(rf"^# ADR-{m.group(1)}:[ \t]*\S", text, re.M): + add(f.name, f'no "# ADR-{m.group(1)}: " heading') + if not ADR_STATUS.search(text): + add(f.name, "no | **Status** | row") + return out + + def lint(root: Path) -> list[tuple[str, int, str, str]]: findings = [] if (root / "docs" / "index.md").is_file(): @@ -152,9 +183,10 @@ def lint(root: Path) -> list[tuple[str, int, str, str]]: text = f.read_text(encoding="utf-8") for n, rule, msg in lint_file(text, rel, known, owned, umbrella): findings.append((rel.as_posix(), n, rule, msg)) - return findings + return findings + adr_findings(root) +ADR_OK = "# ADR-0001: Use X\n\n| | |\n|---|---|\n| **Status** | Accepted |\n" CASES = [ ( "recipe_defined_ok", @@ -210,17 +242,42 @@ def lint(root: Path) -> list[tuple[str, int, str, str]]: {"docs/x.md": "[ok](../README.md) and [up](../../marola-app/README.md)\n", "README.md": ""}, {"e"}, ), + ("adr_good_ok", {"docs/adr/0001-x.md": ADR_OK}, set()), + ( + "adr_space_heading_fails", + {"docs/adr/0001-x.md": ADR_OK.replace("ADR-0001", "ADR 0001")}, + {"f"}, + ), + ( + "adr_wrong_number_fails", + {"docs/adr/0001-x.md": ADR_OK.replace("ADR-0001", "ADR-0002")}, + {"f"}, + ), + ( + "adr_empty_decision_fails", + {"docs/adr/0001-x.md": ADR_OK.replace("ADR-0001: Use X", "ADR-0001: ")}, + {"f"}, + ), + ("adr_bad_file_name_fails", {"docs/adr/1-x.md": ADR_OK}, {"f"}), + ("adr_no_status_fails", {"docs/adr/0001-x.md": "# ADR-0001: Use X\n\nBody.\n"}, {"f"}), + ("adr_readme_fails", {"docs/adr/0001-x.md": ADR_OK, "docs/adr/README.md": "# ADRs\n"}, {"f"}), + ("adr_index_fails", {"docs/adr/0001-x.md": ADR_OK, "docs/adr/index.md": "# ADRs\n"}, {"f"}), + ("adr_nested_ignored_ok", {"docs/adr/old/notes.md": "# Notes\n"}, set()), ] UMBRELLA_TEXT = "See docs/1-Using-marola/RUN-LOCALLY.md and docs/2-Building-marola/REPOS.md.\n" GITMODULES = '[submodule "marola-app"]\n\tpath = marola-app\n\turl = https://github.com/marola-dev/marola-app.git\n' -def _rules(files: dict[str, str]) -> set[str]: +def _findings(files: dict[str, str]) -> list[tuple[str, int, str, str]]: with tempfile.TemporaryDirectory() as tmp: for rel, text in files.items(): (Path(tmp) / rel).parent.mkdir(parents=True, exist_ok=True) (Path(tmp) / rel).write_text(text, encoding="utf-8") - return {rule for _, _, rule, _ in lint(Path(tmp))} + return lint(Path(tmp)) + + +def _rules(files: dict[str, str]) -> set[str]: + return {rule for _, _, rule, _ in _findings(files)} def self_test() -> int: @@ -241,6 +298,17 @@ def ok(name: str, got, want) -> None: elsewhere = _rules({"README.md": UMBRELLA_TEXT}) ok("umbrella_owns_its_numbered_dirs_ok", (umbrella, elsewhere), (set(), {"c"})) + ok( + "adr_heading_messages_match_umbrella", + {m for _, _, _, m in _findings({"docs/adr/0001-x.md": "# ADR 0001: x\n"})}, + {'no "# ADR-0001: " heading', "no | **Status** | row"}, + ) + ok( + "adr_file_messages_match_umbrella", + {m for _, _, _, m in _findings({"docs/adr/1-x.md": ADR_OK, "docs/adr/README.md": ""})}, + {"not NNNN-.md", "hand-written; prepare-docs generates adr/index.md"}, + ) + if fails: print(f"docs-lint self-test: {fails} failure(s)", file=sys.stderr) return 1