From b972d90b6127a92f1aefce4246c433b904bb7d5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:54:30 +0000 Subject: [PATCH 1/3] feat(check-docs): assert every relative Markdown link resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_adr_numbers checks that ADR files are numbered right; nothing checked that a link to one lands. Two broken links shipped in a week — a template naming a mis-typed ADR file, and docs/observability.md using ../adr/ from docs/ (which resolves to a nonexistent repository-root /adr/). This adds a twelfth assertion: every inline Markdown link in every tracked file resolves to a file in the tree, and its anchor, if any, to a heading, the way check_runbook_urls already checks a critical alert's runbook_url. It runs over records too, since a dead link is a structural fault rather than a claim about now. Also fixes the two ../adr/ links in docs/observability.md the new check caught, and bumps README's assertion count to twelve. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A4769sNhNE79DH3SBN4YXx --- README.md | 2 +- docs/observability.md | 4 +- scripts/check_docs.py | 98 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 01cf6cef..879e67b0 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ documents for different readers. `promtool`, `amtool`, `alloy fmt`, a real Loki boot to parse the LogQL rules, dashboard-JSON and datasource checks, every dashboard's PromQL parsed, plus `gitleaks` over the full history. -- **CI that validates the documentation too.** Eleven assertions cross-check +- **CI that validates the documentation too.** Twelve assertions cross-check this prose against the configs it describes — counted claims (rules, dashboards, panels, Alloy agents, VLANs, ADRs, runbooks), the SNMP inventory and the compute table against `docs/network.md`, the host/stack diff --git a/docs/observability.md b/docs/observability.md index 72341bba..9d2aa5cb 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -676,7 +676,7 @@ VM's qemu-guest-agent for `get-fsinfo` every ten minutes and writes (the six-hour extrapolation, under 30% free) are both **critical**: a lab guest is not somewhere anyone looks, and `odin`'s root reached 98% on 2026-10-01 with nothing to say so ([#778](https://github.com/Gerrrt/HomeLab/issues/778)). -[ADR-0070](../adr/0070-let-guest-disk-capacity-cross-read-through-the-hypervisor.md) +[ADR-0070](adr/0070-let-guest-disk-capacity-cross-read-through-the-hypervisor.md) records why this crosses when ADR-0028 kept guest metrics in the lab, and why the agent's answer is treated as hostile input. `GuestAgentSilent` warns when a guest's agent stops answering, and `GuestDiskStateStale` when the collector @@ -695,7 +695,7 @@ after fifteen minutes. A blind SOC is serious, and not a 2 a.m. page. `GuestServiceUnchecked` warns when the agent has not run the check for an hour, and `GuestServiceStateStale` when the collector stops writing ([#858](https://github.com/Gerrrt/HomeLab/issues/858), -[ADR-0088](../adr/0088-let-a-named-lab-services-health-cross-read-through-the-guest-agent.md)). +[ADR-0088](adr/0088-let-a-named-lab-services-health-cross-read-through-the-guest-agent.md)). ### Routing diff --git a/scripts/check_docs.py b/scripts/check_docs.py index 4bba5757..f4e5466c 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -17,7 +17,7 @@ The device list must live in exactly one place. It is currently spread across five ... and --check asserts the other copies still agree. -Eleven assertions, each comparing prose against something machine-readable: +Twelve assertions, each comparing prose against something machine-readable: 1. Counted claims rules, unit-test coverage, dashboards, panels, Alloy agents, ADRs, runbooks, and the size of this @@ -46,6 +46,10 @@ 11. Runbook URLs every critical alert's runbook_url names a file in docs/runbooks/ and, if it has one, an anchor that is a heading in that file (#842). + 12. Relative links every relative Markdown link, in every tracked file, + resolves to a file in the tree, and its anchor to a + heading. The one check that runs over records too: a + dead link is a structural fault, not a claim. Only present-tense documents are checked. `docs/changelog.md`, `docs/roadmap.md` and `docs/adr/` record what was true when the work landed — `changelog.md` @@ -1364,6 +1368,97 @@ def check_runbook_urls() -> list[str]: return problems +LINK_SKIP_DIRS = ( + "node_modules", + ".venv", + ".git", + "ansible/.collections", + "ansible/.ansible", + "tofu/.terraform", + ".claude/worktrees", +) + +# Inline Markdown links and images: [text](target) and ![alt](target). Not +# reference definitions (`[label]: url`), which are footnote targets here and +# whose own `(url)` the inline form already catches. +_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") + + +def _links_outside_fences(text: str): + """The inline link targets in a Markdown file, skipping fenced code. + + A `](path)` inside a ``` or ~~~ block is an example, not a live link, so + it is not resolved — the same fence rule markdown_anchors uses. + """ + fence = "" + for line in text.splitlines(): + m = re.match(r"(`{3,}|~{3,})", line.lstrip()) + if m: + if not fence: + fence = m.group(1)[0] + elif m.group(1)[0] == fence: + fence = "" + continue + if fence: + continue + for lm in _LINK_RE.finditer(line): + yield lm.group(1) + + +def check_relative_links() -> list[str]: + """Every relative Markdown link resolves to a file, and its anchor to a heading. + + Two broken links shipped in one week — a template pointing at a mis-typed + ADR filename, and an ADR using `../adr/` from `docs/` (which resolves to a + repository-root `/adr/` that does not exist). `check_adr_numbers` checks + that ADR files are numbered right; nothing checked that a link *to* one + lands. This does, the same way `check_runbook_urls` checks a critical + alert's `runbook_url`: the target file is in the tree, and the anchor, if + any, is a heading in it. + + Unlike the claim checks above, this runs over every tracked Markdown file, + records included. A link in `changelog.md` that points at a renamed file is + broken whether or not the prose around it is a dated record — a dead link + is a structural fault, not a claim about now. External links (`http(s)`, + `mailto`) are not reached out to; only relative targets inside the repo. + """ + problems: list[str] = [] + anchors: dict[pathlib.Path, set[str]] = {} + + def anchors_of(path: pathlib.Path) -> set[str]: + if path not in anchors: + anchors[path] = markdown_anchors(path.read_text(encoding="utf-8")) + return anchors[path] + + for md in sorted(REPO.rglob("*.md")): + rel = md.relative_to(REPO) + if any(str(rel).startswith(d + "/") for d in LINK_SKIP_DIRS): + continue + for raw in _links_outside_fences(md.read_text(encoding="utf-8")): + target = raw.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1].strip() + # Drop a link title: [text](path "Title"). + target = target.split()[0] if target.split() else target + if not target or target.startswith( + ("http://", "https://", "mailto:", "tel:") + ): + continue + path_part, _, anchor = target.partition("#") + if not path_part: + # A same-file anchor, [text](#heading). + if anchor and anchor not in anchors_of(md): + problems.append(f"{rel}: #{anchor} is not a heading in this file") + continue + dest = (md.parent / path_part).resolve() + if not dest.exists(): + problems.append(f"{rel}: link to {target} — {path_part} does not exist") + continue + if anchor and dest.suffix == ".md" and anchor not in anchors_of(dest): + problems.append(f"{rel}: {path_part} has no heading for #{anchor}") + return problems + + def main() -> int: f = facts() checks = ( @@ -1378,6 +1473,7 @@ def main() -> int: ("guest claims against each other", check_guest_claims), ("README's outstanding-purchase count against the roadmap's table", check_buy_list), ("critical alerts' runbook_url against docs/runbooks/", check_runbook_urls), + ("relative Markdown links resolve to a file and an anchor", check_relative_links), ) # The registry is the source for README's "N assertions", so adding a From 4794f3e940518f7dfd12ea25da71a1a43dcca80a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:56:43 +0000 Subject: [PATCH 2/3] style(check-docs): ruff format the link checker CI runs ruff format --check under --require-all; the if condition it wanted collapsed to one line. No behaviour change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A4769sNhNE79DH3SBN4YXx --- scripts/check_docs.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/check_docs.py b/scripts/check_docs.py index f4e5466c..02728c09 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -1440,9 +1440,7 @@ def anchors_of(path: pathlib.Path) -> set[str]: target = target[1:-1].strip() # Drop a link title: [text](path "Title"). target = target.split()[0] if target.split() else target - if not target or target.startswith( - ("http://", "https://", "mailto:", "tel:") - ): + if not target or target.startswith(("http://", "https://", "mailto:", "tel:")): continue path_part, _, anchor = target.partition("#") if not path_part: From 4b146cfcd0471f2b9de12a7597ed50d880f9be0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:02:32 +0000 Subject: [PATCH 3/3] feat(check-docs): cover ref-links, test it, scope to tracked, stay in-repo Copilot's review of #1193: - Parse reference definitions ([label]: target) and the outer target of a linked badge [![alt](img)](target), both of which the repo uses and the first cut missed. Footnotes ([^x]:) and label lines followed by unquoted prose are not link definitions, per CommonMark. - Extraction and resolution move into markdown_link_targets and relative_link_problems, which --self-test now exercises with fixtures for every form and for missing files, missing anchors and out-of-repo targets. - Discover files with git ls-files, not rglob, so untracked scratch files and the .claude/worktrees copies are not scanned (the check_image_pins.py convention). - Reject a target that resolves outside the repository: GitHub cannot render a link that leaves the checkout, so an absolute path or an escaping ../ is as broken as a missing file. - README's assertion list and the module docstring now name the relative-link check and its records-wide scope. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A4769sNhNE79DH3SBN4YXx --- README.md | 3 +- scripts/check_docs.py | 209 ++++++++++++++++++++++++++++++------------ 2 files changed, 150 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 879e67b0..3b7225e8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ documents for different readers. and ports tables against `compose.yaml`, ADR numbering, firewall posture against `docs/firewall-claims.yaml`, guest rows against each other, this file's outstanding-purchase count against the roadmap's buy table, every - critical alert's `runbook_url` against the runbook and heading it names, and + critical alert's `runbook_url` against the runbook and heading it names, + every relative Markdown link against the file and heading it points at, and a ban on image versions in prose — Dependabot edits only `compose.yaml`, so a version written anywhere else is stale from the next bump. A document that disagrees with the repository fails the build. That opening count is diff --git a/scripts/check_docs.py b/scripts/check_docs.py index 02728c09..6ad8397b 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -51,9 +51,12 @@ heading. The one check that runs over records too: a dead link is a structural fault, not a claim. -Only present-tense documents are checked. `docs/changelog.md`, `docs/roadmap.md` -and `docs/adr/` record what was true when the work landed — `changelog.md` -still says "(34 rules)" in a 2026-08 entry, and that is correct as written. +Only present-tense documents are checked for their claims — the relative-link +assertion (12) is the exception, and scans every tracked file including the +records, because a dead link is a structural fault, not a claim. `docs/changelog.md`, +`docs/roadmap.md` and `docs/adr/` record what was true when the work landed — +`changelog.md` still says "(34 rules)" in a 2026-08 entry, and that is correct +as written, and its *claims* are not checked here. Failing on those would need an ignore list, and this repository has already learned where that leads: the `.gitleaksignore` deleted in the history purge "was an acknowledgement, not a @@ -77,6 +80,7 @@ import json import pathlib import re +import subprocess import sys # PyYAML from the one pinned bootstrap, scripts/_deps.py (#848). @@ -1368,28 +1372,32 @@ def check_runbook_urls() -> list[str]: return problems -LINK_SKIP_DIRS = ( - "node_modules", - ".venv", - ".git", - "ansible/.collections", - "ansible/.ansible", - "tofu/.terraform", - ".claude/worktrees", -) +# Every `](target)` — inline links, images, and the outer target of a linked +# image `[![alt](img)](target)`, which a `!?\[...\]\(...\)` form misses because +# it stops at the inner image. Reference definitions are matched separately. +_INLINE_TARGET = re.compile(r"\]\(([^)]+)\)") +# A link reference definition at line start: `[label]: target` with only a +# destination, optionally a quoted title. A footnote (`[^label]:`) or a line +# whose "target" is followed by unquoted prose is not one — per CommonMark it +# is a paragraph, so `[^lan]: True in both directions` is not a link. +_REF_DEF = re.compile(r"""^\s{0,3}\[(?!\^)[^\]]+\]:\s+(\S+)\s*(?:["'(].*)?$""") -# Inline Markdown links and images: [text](target) and ![alt](target). Not -# reference definitions (`[label]: url`), which are footnote targets here and -# whose own `(url)` the inline form already catches. -_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") +def markdown_link_targets(text: str) -> list[str]: + """Every link target in a Markdown file's prose, skipping fenced code. -def _links_outside_fences(text: str): - """The inline link targets in a Markdown file, skipping fenced code. + Covers the three forms this repository uses: inline `[text](target)` and + `![alt](target)`, the outer target of a linked badge `[![alt](img)](target)` + (both the inner image and the outer link are yielded), and reference + definitions `[label]: target`. A `](...)` or definition inside a ``` or + ~~~ block is an example, not a live link — the same fence rule + markdown_anchors uses. - A `](path)` inside a ``` or ~~~ block is an example, not a live link, so - it is not resolved — the same fence rule markdown_anchors uses. + A reference definition whose target itself opens with `[` is a footnote + carrying a nested link (`[^x]: [text](url)`); its real `](url)` is caught by + the inline pass, so the `[text` fragment is not treated as a target. """ + targets: list[str] = [] fence = "" for line in text.splitlines(): m = re.match(r"(`{3,}|~{3,})", line.lstrip()) @@ -1401,8 +1409,71 @@ def _links_outside_fences(text: str): continue if fence: continue - for lm in _LINK_RE.finditer(line): - yield lm.group(1) + targets.extend(lm.group(1) for lm in _INLINE_TARGET.finditer(line)) + ref = _REF_DEF.match(line) + if ref: + targets.append(ref.group(1)) + return targets + + +def _tracked_markdown() -> list[pathlib.Path]: + """Tracked `*.md` paths, from git rather than a walk of the working tree. + + The convention `check_image_pins.py` sets, and for its reasons: a recursive + walk descends into the git worktrees this repository keeps under + `.claude/worktrees/` and reports another branch's files, and an untracked + scratch `.md` is nobody's concern and must not fail a local run. + """ + out = subprocess.run( + ["git", "ls-files", "-z", "*.md"], + cwd=REPO, + capture_output=True, + text=True, + check=True, + ) + return [REPO / rel for rel in out.stdout.split("\0") if rel] + + +def relative_link_problems(rel: str, text: str, root: pathlib.Path) -> list[str]: + """The broken relative links in one Markdown file's text (pure; testable). + + `rel` is the file's path for messages, `text` its contents, `root` the + repository it lives in. External links (`http(s)`, `mailto`, `tel`) are not + reached out to. A target that resolves outside `root` fails: GitHub cannot + render a link that leaves the repository, so an absolute path or a `../` + that escapes the checkout is as broken as a missing file. + """ + problems: list[str] = [] + here = (root / rel).parent + own_anchors = markdown_anchors(text) + + def anchors_of(path: pathlib.Path) -> set[str]: + return markdown_anchors(path.read_text(encoding="utf-8")) + + for raw in markdown_link_targets(text): + target = raw.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1].strip() + target = target.split()[0] if target.split() else target # drop a title + if not target or target.startswith(("http://", "https://", "mailto:", "tel:")): + continue + path_part, _, anchor = target.partition("#") + if not path_part: # a same-file anchor, [text](#heading) + if anchor and anchor not in own_anchors: + problems.append(f"{rel}: #{anchor} is not a heading in this file") + continue + dest = (here / path_part).resolve() + try: + dest.relative_to(root.resolve()) + except ValueError: + problems.append(f"{rel}: link to {target} leaves the repository") + continue + if not dest.exists(): + problems.append(f"{rel}: link to {target} — {path_part} does not exist") + continue + if anchor and dest.suffix == ".md" and anchor not in anchors_of(dest): + problems.append(f"{rel}: {path_part} has no heading for #{anchor}") + return problems def check_relative_links() -> list[str]: @@ -1413,47 +1484,19 @@ def check_relative_links() -> list[str]: repository-root `/adr/` that does not exist). `check_adr_numbers` checks that ADR files are numbered right; nothing checked that a link *to* one lands. This does, the same way `check_runbook_urls` checks a critical - alert's `runbook_url`: the target file is in the tree, and the anchor, if - any, is a heading in it. - - Unlike the claim checks above, this runs over every tracked Markdown file, - records included. A link in `changelog.md` that points at a renamed file is - broken whether or not the prose around it is a dated record — a dead link - is a structural fault, not a claim about now. External links (`http(s)`, - `mailto`) are not reached out to; only relative targets inside the repo. + alert's `runbook_url`: the target is a file in the tree, inside the + repository, and its anchor, if any, is a heading in it. + + Unlike the claim checks, this runs over every tracked Markdown file, + records included: a link in `changelog.md` or an ADR that points at a + renamed file is broken whether or not the prose around it is a record, so + the present-tense scope does not apply to it. The parsing and resolution + live in `relative_link_problems`, which `--self-test` exercises directly. """ problems: list[str] = [] - anchors: dict[pathlib.Path, set[str]] = {} - - def anchors_of(path: pathlib.Path) -> set[str]: - if path not in anchors: - anchors[path] = markdown_anchors(path.read_text(encoding="utf-8")) - return anchors[path] - - for md in sorted(REPO.rglob("*.md")): - rel = md.relative_to(REPO) - if any(str(rel).startswith(d + "/") for d in LINK_SKIP_DIRS): - continue - for raw in _links_outside_fences(md.read_text(encoding="utf-8")): - target = raw.strip() - if target.startswith("<") and target.endswith(">"): - target = target[1:-1].strip() - # Drop a link title: [text](path "Title"). - target = target.split()[0] if target.split() else target - if not target or target.startswith(("http://", "https://", "mailto:", "tel:")): - continue - path_part, _, anchor = target.partition("#") - if not path_part: - # A same-file anchor, [text](#heading). - if anchor and anchor not in anchors_of(md): - problems.append(f"{rel}: #{anchor} is not a heading in this file") - continue - dest = (md.parent / path_part).resolve() - if not dest.exists(): - problems.append(f"{rel}: link to {target} — {path_part} does not exist") - continue - if anchor and dest.suffix == ".md" and anchor not in anchors_of(dest): - problems.append(f"{rel}: {path_part} has no heading for #{anchor}") + for md in sorted(_tracked_markdown()): + rel = str(md.relative_to(REPO)) + problems += relative_link_problems(rel, md.read_text(encoding="utf-8"), REPO) return problems @@ -1521,6 +1564,50 @@ def self_test() -> int: ("nor inside a ~~~ fence", "also-not-a-heading" not in got), ("a heading after a closed fence is one", "after-the-fence" in got), ] + # The link parser and resolver check_relative_links depends on. Built as a + # temp tree so the resolution, not only the parsing, is exercised. + import tempfile + + extracted = markdown_link_targets( + "[a](one.md) and ![i](img.png)\n" + "[![alt](badge.svg)](outer.md#sec)\n" + "[ref]: ../two.md\n" + "[^note]: True, and not a link at all\n" + "[bad]: also not a link, just prose\n" + "```\n[x](skipped.md)\n```\n" + ) + cases += [ + ("an inline link target is found", "one.md" in extracted), + ("an image target is found", "img.png" in extracted), + ("a linked badge yields its outer target", "outer.md#sec" in extracted), + ("and its inner image target", "badge.svg" in extracted), + ("a reference definition is found", "../two.md" in extracted), + ("a footnote is not a link", "True," not in extracted), + ("prose after a label is not a link", "also" not in extracted), + ("a target inside a fence is skipped", "skipped.md" not in extracted), + ] + with tempfile.TemporaryDirectory() as d: + root = pathlib.Path(d) + (root / "b.md").write_text("# Top\n## A Section\n") + (root / "img.png").write_bytes(b"") + text = ( + "[ok](b.md)\n[anchor-ok](b.md#a-section)\n[img-ok](img.png)\n" + "[self-ok](#here)\n# Here\n" + "[missing](nope.md)\n[anchor-bad](b.md#ghost)\n" + "[self-bad](#ghost)\n[escape](../outside.md)\n" + ) + probs = relative_link_problems("a.md", text, root) + joined = " | ".join(probs) + cases += [ + ("a valid file link passes", "b.md —" not in joined), + ("a valid cross-file anchor passes", "a-section" not in joined), + ("a valid same-file anchor passes", "#here" not in joined), + ("a missing file is caught", "nope.md does not exist" in joined), + ("a missing cross-file anchor is caught", "no heading for #ghost" in joined), + ("a missing same-file anchor is caught", "#ghost is not a heading" in joined), + ("a target leaving the repository is caught", "leaves the repository" in joined), + ] + failed = 0 for name, ok in cases: failed += not ok