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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ marketplace `ref` at once.
- `docs-lint` rule `f` checks the ADR shape the umbrella's docs build requires: `docs/adr/NNNN-<slug>.md`,
an `# ADR-NNNN: <decision>` 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).
- `docs-lint` rule `g` fails an image link (`![…](…)`, an HTML `src=`, or a target ending in an image suffix)
that stays in the repo and resolves outside `docs/`, as the umbrella's docs build does (#126).

## v0.11.1 — 2026-10-15

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ non-fork repo of an org (`--include-forks` adds forks). `just rulesets-check`/`j

**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`), and checks each
`docs/adr/*.md` for the umbrella's ADR shape (rule `f`); a repo
`docs/adr/*.md` for the umbrella's ADR shape (rule `f`); rule `g` fails an image link that resolves
outside `docs/`, as the umbrella's docs build does. 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
Expand Down
43 changes: 40 additions & 3 deletions scripts/docs_lint.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Stale-content check over README.md and docs/**/*.md (MIP-0074 §7, rules a–f).
"""Stale-content check over README.md and docs/**/*.md (MIP-0074 §7, rules a–g).

docs-lint [repo-root]
docs-lint --self-test
Expand Down Expand Up @@ -35,8 +35,9 @@
LINK_TARGETS = [
re.compile(r"!?\[[^\]]*\]\(\s*<?([^)\s>]+)"),
re.compile(r"^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)"),
re.compile(r"""\b(?:href|src)\s*=\s*["']([^"']+)["']"""),
re.compile(r"""(?<![\w-])(?:href|src)\s*=\s*["']([^"']+)["']""", re.I),
]
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico"}
RECIPE = re.compile(r"^@?([A-Za-z_][\w-]*)[^:\n]*:(?!=)", re.M)
ALIAS = re.compile(r"^alias\s+([\w-]+)\s*:=", re.M)

Expand Down Expand Up @@ -129,8 +130,20 @@ def lint_file(text: str, rel: Path, known: set[str], owned: set[str], umbrella:
or re.match(r"[a-zA-Z][\w+.-]*:", target)
):
continue
if os.path.normpath(rel.parent / target).startswith(".."):
resolved = os.path.normpath(rel.parent / target)
if resolved.startswith(".."):
out.append((start + k, "e", f"{m.group(1)} leaves the repo"))
continue
# The umbrella publishes README.md and docs/ only (doc_links.py `rewrite`).
# Trap: in the umbrella, <submodule>/docs/ images pass its build but fail here.
image = (
m.group(0).startswith("!")
or m.group(0).lower().startswith("src")
or Path(resolved).suffix.lower() in IMAGE_SUFFIXES
)
if image and resolved != "docs" and not resolved.startswith("docs/"):
msg = f"{m.group(1)}: an image outside docs/; move it under docs/"
out.append((start + k, "g", msg))
return out


Expand Down Expand Up @@ -242,6 +255,24 @@ 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"},
),
("image_md_outside_docs_fails", {"README.md": "![a](drawings/x.svg)\n"}, {"g"}),
("image_src_outside_docs_fails", {"README.md": '<img src="drawings/x.svg">\n'}, {"g"}),
("image_suffix_outside_docs_fails", {"README.md": "[a](assets/x.png)\n"}, {"g"}),
("image_refdef_suffix_outside_docs_fails", {"README.md": "[a]: assets/x.PNG\n"}, {"g"}),
("image_query_outside_docs_fails", {"README.md": "![a](drawings/x.svg?raw=1#f)\n"}, {"g"}),
("image_under_docs_from_readme_ok", {"README.md": "![a](docs/img/x.svg)\n"}, set()),
("image_under_docs_ok", {"docs/page.md": "![a](img/x.svg)\n"}, set()),
("image_url_ok", {"README.md": "![a](https://example.com/x.svg)\n"}, set()),
("image_in_fence_ok", {"README.md": "```md\n![a](drawings/x.svg)\n```\n"}, set()),
("image_in_code_span_ok", {"README.md": "Write `![a](drawings/x.svg)` to embed.\n"}, set()),
("link_to_non_image_outside_docs_ok", {"README.md": "[a](LICENSE)\n"}, set()),
("image_upper_src_outside_docs_fails", {"README.md": '<IMG SRC="drawings/x">\n'}, {"g"}),
(
"image_data_src_ignored_ok",
{"README.md": '<img data-src="drawings/x" src="docs/img/x.png">\n'},
set(),
),
("image_leaving_repo_is_e_only", {"docs/x.md": "![a](../../x.png)\n"}, {"e"}),
("adr_good_ok", {"docs/adr/0001-x.md": ADR_OK}, set()),
(
"adr_space_heading_fails",
Expand Down Expand Up @@ -309,6 +340,12 @@ def ok(name: str, got, want) -> None:
{"not NNNN-<slug>.md", "hand-written; prepare-docs generates adr/index.md"},
)

ok(
"image_message_matches_umbrella",
{m for _, _, _, m in _findings({"README.md": "![a](drawings/x.svg)\n"})},
{"drawings/x.svg: an image outside docs/; move it under docs/"},
)

if fails:
print(f"docs-lint self-test: {fails} failure(s)", file=sys.stderr)
return 1
Expand Down
Loading