From bd90230f7cf50e5a7c1f492bcf9ada46aa17f68c Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 26 Aug 2026 16:52:13 +0100 Subject: [PATCH 1/6] DOC-7003 add an offline checker for internal links and anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds build/check_internal_anchors.py, 13 tests, and a make target. It resolves two classes of link that nothing currently validates: absolute redis.io self-links, which Hugo treats as external and .lychee.toml excludes as internal, and relref with an anchor, where the page resolves so the build passes while the fragment is dead. The ticket's self-link count needed reconciling first, and the answer was that the two figures in it measure different things. The built site emits 7,639 absolute redis.io hrefs, but roughly 28 of those are on every page -- footer legal links, try-free, nav -- so that number is theme chrome multiplied by the page count and is useless as a lint target. Hand-authored self-links in live source are 199 distinct across 309 instances. So the checker finds links in source and resolves them against the built tree, rather than extracting hrefs from the build. Only 110 of those 199 are resolvable at all. The rest point at redis.io/blog, /legal, /pricing and similar, which are the marketing site, built elsewhere and legitimately absent from public/. Reporting them as broken would have buried the real findings under twice their number, so they are skipped explicitly. Anchors are read out of Hugo's own output rather than derived from a slug rule, because this repo already has three disagreeing slug implementations and a fourth would be a liability. Two false-positive classes showed up on the first run and are handled rather than tolerated. Non-page artifacts -- sitemap.xml, docs.ndjson, the .md twin of each page -- are real files, so the resolver tries the literal path before treating a path as a page. And the OpenAPI reference pages mount their content with Redoc, so their static HTML carries 3 anchors against 130 on a normal page; those anchors are real in a browser and unreadable here. They are now a third verdict, unverifiable, which removed 71 of an unfiltered 181 findings. A checker that called them missing would be the kind of report people learn to ignore. What survives is 110 findings, of which about 104 are dead relref anchors. Three were verified by hand against the built pages before trusting the number, and all three were real -- one where the target page had split "Create certificates" into four narrower sections, leaving the anchor behind. The suite was also mutation tested: short-circuiting the anchor comparison turns two tests red, so a green run means something. Learned: the ticket's two self-link counts measured different domains, and the built-site figure was theme chrome times page count -- reconcile a count's definition before designing anything around it. Constraint: anchors must come from the built HTML, never from a reimplemented slug rule; three implementations already disagree in this repo. Rejected: extracting self-links from the built site the way extract_external_urls.sh does | every page carries ~28 theme-generated redis.io hrefs, so it measures the template, not the writing Directive: keep the three-way verdict — a page whose content is client-rendered is unverifiable, never missing, or the report becomes noise nobody reads. Gaps: the ~104 relref findings are verified on a sample of three, not individually; docs.ndjson resolves only after a full `make ndjson`, so a bare `hugo` build reports it absent; the post-merge workflow is not wired up yet. Ticket: DOC-7003 Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 7 + build/check_internal_anchors.py | 251 +++++++++++++++++++++++++++ build/test_check_internal_anchors.py | 140 +++++++++++++++ 3 files changed, 398 insertions(+) create mode 100755 build/check_internal_anchors.py create mode 100644 build/test_check_internal_anchors.py diff --git a/Makefile b/Makefile index c5a3a2e865..06c4276577 100644 --- a/Makefile +++ b/Makefile @@ -107,6 +107,13 @@ check_aliases: check_aliases_fix: @python3 build/check_missing_aliases.py --all --fix +# Report internal links whose target page or heading anchor doesn't exist. +# Needs a built site: run `make hugo` (or the full `make all`) first. Unlike +# check_aliases this reads public/, because heading anchors come from Hugo's own +# output rather than from a reimplemented slug rule. +check_internal_anchors: + @python3 build/check_internal_anchors.py + clean: @rm -Rf ./public/ @rm -Rf ./resources/ diff --git a/build/check_internal_anchors.py b/build/check_internal_anchors.py new file mode 100755 index 0000000000..b352b46a77 --- /dev/null +++ b/build/check_internal_anchors.py @@ -0,0 +1,251 @@ +"""Report internal links whose target page or heading anchor doesn't exist. + +Two classes of internal link are invisible to the build, and both were behind +DOC-6998 and DOC-7003: + +1. **Absolute self-links.** ``https://redis.io/docs/...`` written out in full rather + than as a ``relref``. Hugo treats them as external and never resolves them; + ``.lychee.toml`` excludes ``redis.io`` as internal. So they fall in a hole and + nothing checks them. Four legacy ones hard-404 on the live site today. + +2. **relref with an anchor.** ``{{< relref "/a/b#some-heading" >}}`` resolves the + *page*, so a broken ``#anchor`` builds clean and fails silently in the reader's + browser. `relref` validates pages, never headings. + +Both are deterministic and offline: the answer is in the built ``public/`` tree, so +there is no network, no rate limiting and no false-positive story of the kind that +makes external anchor checking unusable. + +Design notes, each of which is load-bearing: + +* **Anchors come from Hugo's own output, never from a reimplemented slug rule.** + This repo already contains three disagreeing slug implementations (DOC-6905); a + fourth would be a liability. Reading ``id=``/``name=`` out of the built HTML uses + Hugo as the oracle. +* **Only the docs tree is resolvable.** ``redis.io/blog/``, ``/legal/``, ``/pricing/`` + and friends are the marketing site, built elsewhere and absent from ``public/``. + They are reported as SKIPPED, never as broken -- 121 of the 199 distinct + self-links are of that kind, so treating them as missing would bury the real ones. +* **Alias stubs count as resolved.** Hugo writes a redirecting stub at an alias URL, + which is exactly what a reader gets, so a link to an aliased path is not broken. +* **The URL prefix is configurable** because CI rewrites ``baseURL`` to include a + path prefix (``https://redis.io/docs/latest``) while ``public/`` stays rooted at + the tree root. A mapping hardcoded to the local layout would silently pass in CI. +* **Attribute quoting is optional in the regex.** Hugo drops optional quotes when + minifying. This site doesn't minify today, but assuming quotes is precisely the + bug that made an earlier audit report 23 present anchors as missing. +* **Legacy ```` anchors are part of the pool.** ``protocol-spec.md`` + alone defines 17 of them, and command pages link to them by name. + +Usage:: + + build/check_internal_anchors.py [--public public] [--content content] [--json] + +Exits 1 when anything is MISSING, 0 otherwise. ``--json`` prints machine-readable +findings for a workflow to post. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from urllib.parse import unquote + +# Mirrors SKIP_SOURCE_PATHS in build/extract_external_urls.sh. Kept textually +# identical so the two tools cannot disagree about what "current docs" means -- +# archived version trees and release notes are frozen and out of scope. +SKIP_SOURCE_PATHS = re.compile( + r"/(kubernetes|rs|rc|redis-data-integration|redisvl)/[0-9]" + r"|/release-notes/" + r"|/legacy-release-notes/" +) + +# Path prefixes stripped from a self-link before resolving it against public/. +# Longest first: /docs/latest/ must win over /docs/. +URL_PREFIXES = ("/docs/latest/", "/docs/staging/", "/docs/") + +# First path segments that live in the docs tree. Anything else on redis.io is the +# marketing site and cannot be resolved from public/. +DOCS_SEGMENTS = { + "docs", "commands", "develop", "operate", "integrate", "embeds", +} + +SELF_LINK_PATTERNS = ( + re.compile(r"\]\(\s*(https?://(?:www\.)?redis\.io[^\s)]*)\s*\)"), + re.compile(r"""href=["'](https?://(?:www\.)?redis\.io[^"']*)["']"""), + re.compile(r"<(https?://(?:www\.)?redis\.io[^>\s]*)>"), +) + +RELREF_ANCHORED = re.compile( + r"""relref\s+["'](?P/[^"'#]*)#(?P[^"']+)["']""" +) + +# id= / name= with double, single, or absent quotes. +# A page that mounts its content client-side (the OpenAPI reference pages use Redoc) +# exposes almost no anchors in its static HTML -- the api-reference page has 3 against +# 130 on a normal content page. Its anchors are real in the browser and invisible here, +# so they are UNVERIFIABLE, never missing. Reporting them would be the single largest +# false-positive class: 69 of an unfiltered 172 findings. +CLIENT_RENDERED = re.compile(r"redoc|swagger-ui|rapidoc", re.I) + +ANCHOR_ATTR = re.compile( + r"""(?:id|name)\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_:.\-]+))""" +) + + +def live_sources(content: Path) -> list[Path]: + """Markdown files representing current docs (archives excluded).""" + return sorted( + p for p in content.rglob("*.md") if not SKIP_SOURCE_PATHS.search(p.as_posix()) + ) + + +def anchors_in(html: Path, _cache: dict[Path, set[str]] = {}) -> set[str]: + """Every anchor a browser could jump to on a built page.""" + if html not in _cache: + text = html.read_text(encoding="utf-8", errors="replace") + found = set() + for m in ANCHOR_ATTR.finditer(text): + found.add(next(g for g in m.groups() if g is not None)) + _cache[html] = found + return _cache[html] + + +def is_client_rendered(html: Path, _cache: dict[Path, bool] = {}) -> bool: + """True when the page builds its content in the browser, so anchors can't be read.""" + if html not in _cache: + _cache[html] = bool(CLIENT_RENDERED.search( + html.read_text(encoding="utf-8", errors="replace"))) + return _cache[html] + + +def resolve_page(public: Path, url_path: str) -> Path | None: + """Map a site path to its built HTML file, or None if it isn't in the tree.""" + rel = url_path.strip("/") + # The literal path first: the docs tree publishes non-page artifacts too + # (sitemap.xml, docs.ndjson, the .md twin of every page), and a link to one of + # those is a link to a real file, not a page that failed to resolve. + for candidate in ( + public / rel, + public / rel / "index.html", + public / f"{rel}.html", + ): + if candidate.is_file(): + return candidate + if not rel: + root = public / "index.html" + return root if root.is_file() else None + return None + + +def self_link_target(url: str) -> tuple[str, str] | None: + """Split a self-link into (site path, anchor), or None if outside the docs tree.""" + body = re.sub(r"^https?://(?:www\.)?redis\.io", "", url) + path, _, anchor = body.partition("#") + path = path.split("?", 1)[0] or "/" + first = path.strip("/").split("/", 1)[0] + if first not in DOCS_SEGMENTS: + return None + for prefix in URL_PREFIXES: + if path.startswith(prefix): + path = "/" + path[len(prefix):] + break + return path, unquote(anchor) + + +def check(content: Path, public: Path) -> tuple[list[dict], dict[str, int]]: + findings: list[dict] = [] + tally = {"self_ok": 0, "self_skipped": 0, "relref_ok": 0, + "relref_unhandled": 0, "unverifiable": 0} + + for src in live_sources(content): + rel_src = src.as_posix() + for lineno, line in enumerate(src.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + for pattern in SELF_LINK_PATTERNS: + for m in pattern.finditer(line): + url = m.group(1).rstrip(".,;:") + target = self_link_target(url) + if target is None: + tally["self_skipped"] += 1 + continue + path, anchor = target + page = resolve_page(public, path) + if page is None: + findings.append(dict(kind="self-link", file=rel_src, + line=lineno, target=url, + problem="page not in built site")) + continue + if anchor and is_client_rendered(page): + tally["unverifiable"] += 1 + continue + if anchor and anchor not in anchors_in(page): + findings.append(dict(kind="self-link", file=rel_src, + line=lineno, target=url, + problem=f"anchor #{anchor} not on page")) + continue + tally["self_ok"] += 1 + + for m in RELREF_ANCHORED.finditer(line): + path, anchor = m.group("path"), unquote(m.group("anchor")) + page = resolve_page(public, path) + if page is None: + # A relref that didn't resolve would have failed the build, so + # this means the path shape isn't one we map (not a defect). + tally["relref_unhandled"] += 1 + continue + if is_client_rendered(page): + tally["unverifiable"] += 1 + continue + if anchor not in anchors_in(page): + findings.append(dict(kind="relref", file=rel_src, line=lineno, + target=f'{path}#{anchor}', + problem=f"anchor #{anchor} not on target page")) + continue + tally["relref_ok"] += 1 + + return findings, tally + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--content", type=Path, default=Path("content")) + ap.add_argument("--public", type=Path, default=Path("public")) + ap.add_argument("--json", action="store_true", help="emit findings as JSON") + args = ap.parse_args(argv) + + if not args.public.is_dir(): + print(f"ERROR: '{args.public}' not found. Build the site first (make hugo).", + file=sys.stderr) + return 2 + + findings, tally = check(args.content, args.public) + + # A run that checked nothing must never look like a pass. If the corpus yields + # no resolvable links at all, the scoping or the build is broken, not the docs. + checked = tally["self_ok"] + tally["relref_ok"] + len(findings) + if checked == 0: + print("ERROR: resolved 0 internal links; scoping or the build likely failed.", + file=sys.stderr) + return 2 + + if args.json: + print(json.dumps({"findings": findings, "tally": tally}, indent=2)) + else: + for f in findings: + print(f"{f['file']}:{f['line']}: {f['kind']}: {f['target']} -- {f['problem']}") + print( + f"\nchecked {checked} internal links: " + f"{tally['self_ok']} self-links OK, {tally['relref_ok']} anchored relrefs OK, " + f"{len(findings)} broken " + f"({tally['self_skipped']} self-links outside the docs tree skipped, " + f"{tally['relref_unhandled']} relref paths unmapped)", + file=sys.stderr, + ) + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/test_check_internal_anchors.py b/build/test_check_internal_anchors.py new file mode 100644 index 0000000000..4e29055817 --- /dev/null +++ b/build/test_check_internal_anchors.py @@ -0,0 +1,140 @@ +"""Tests for build/check_internal_anchors.py. + +The two the ticket asks for explicitly are ``test_broken_self_link_fails`` and +``test_relref_with_bad_anchor_fails``. The positive controls matter just as much: +a checker that reports nothing passes a broken-link test for the wrong reason, so +each failure case has a mirror asserting the *valid* form is accepted. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import check_internal_anchors as cia + + +def build_tree(root: Path, pages: dict[str, str], sources: dict[str, str]) -> tuple[Path, Path]: + """Write a minimal public/ and content/ pair and return their paths.""" + public, content = root / "public", root / "content" + for url_path, html in pages.items(): + target = public / url_path.strip("/") / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(html, encoding="utf-8") + for rel, text in sources.items(): + target = content / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + return content, public + + +PAGE = '

Real heading

' + + +class CheckInternalAnchors(unittest.TestCase): + def run_check(self, sources, pages=None): + pages = {"/develop/thing": PAGE} if pages is None else pages + with TemporaryDirectory() as tmp: + content, public = build_tree(Path(tmp), pages, sources) + return cia.check(content, public) + + # --- the two failures the acceptance criteria name ------------------------- + + def test_broken_self_link_fails(self): + findings, _ = self.run_check( + {"a.md": "See [x](https://redis.io/docs/latest/develop/nope/).\n"}) + self.assertEqual(len(findings), 1, findings) + self.assertEqual(findings[0]["kind"], "self-link") + self.assertIn("page not in built site", findings[0]["problem"]) + + def test_relref_with_bad_anchor_fails(self): + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#no-such-heading" >}}.\n'}) + self.assertEqual(len(findings), 1, findings) + self.assertEqual(findings[0]["kind"], "relref") + self.assertIn("no-such-heading", findings[0]["problem"]) + + def test_self_link_with_bad_anchor_fails(self): + findings, _ = self.run_check( + {"a.md": "See [x](https://redis.io/docs/latest/develop/thing/#ghost).\n"}) + self.assertEqual(len(findings), 1, findings) + self.assertIn("#ghost", findings[0]["problem"]) + + # --- positive controls: it must not pass by finding nothing ---------------- + + def test_valid_self_link_passes(self): + findings, tally = self.run_check( + {"a.md": "See [x](https://redis.io/docs/latest/develop/thing/#real-heading).\n"}) + self.assertEqual(findings, []) + self.assertEqual(tally["self_ok"], 1) + + def test_valid_relref_anchor_passes(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#real-heading" >}}.\n'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + + def test_legacy_a_name_anchor_counts(self): + """ is a real jump target and must not be reported missing.""" + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#legacy-anchor" >}}.\n'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + + # --- scoping: things that must NOT be reported ----------------------------- + + def test_marketing_site_link_is_skipped_not_failed(self): + """redis.io/blog is built elsewhere; absence from public/ proves nothing.""" + findings, tally = self.run_check({"a.md": "See [x](https://redis.io/blog/whatever/).\n"}) + self.assertEqual(findings, []) + self.assertEqual(tally["self_skipped"], 1) + + def test_client_rendered_target_is_unverifiable_not_missing(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/operate/api#tag/Cluster/operation/x" >}}.\n'}, + pages={"/operate/api": '
'}) + self.assertEqual(findings, []) + self.assertEqual(tally["unverifiable"], 1) + + def test_archived_tree_is_not_scanned(self): + findings, _ = self.run_check( + {"operate/rs/7.4/old.md": "See [x](https://redis.io/docs/latest/develop/nope/).\n"}) + self.assertEqual(findings, []) + + def test_release_notes_are_not_scanned(self): + findings, _ = self.run_check( + {"operate/release-notes/x.md": "See [x](https://redis.io/docs/latest/develop/nope/).\n"}) + self.assertEqual(findings, []) + + # --- resolution details --------------------------------------------------- + + def test_non_page_artifact_resolves(self): + """sitemap.xml and docs.ndjson are real files, not pages that failed.""" + with TemporaryDirectory() as tmp: + root = Path(tmp) + content, public = build_tree( + root, {"/develop/thing": PAGE}, + {"a.md": "See [x](https://redis.io/docs/latest/sitemap.xml).\n"}) + (public / "sitemap.xml").write_text("", encoding="utf-8") + findings, tally = cia.check(content, public) + self.assertEqual(findings, []) + self.assertEqual(tally["self_ok"], 1) + + def test_prefixless_and_latest_prefix_resolve_alike(self): + """CI rewrites baseURL to include /docs/latest; both forms must map the same.""" + for url in ("https://redis.io/docs/develop/thing/", + "https://redis.io/docs/latest/develop/thing/"): + with self.subTest(url=url): + findings, tally = self.run_check({"a.md": f"See [x]({url}).\n"}) + self.assertEqual(findings, [], url) + self.assertEqual(tally["self_ok"], 1) + + def test_query_string_before_fragment_is_stripped(self): + findings, _ = self.run_check( + {"a.md": "[x](https://redis.io/docs/latest/develop/thing/?utm=1#real-heading)\n"}) + self.assertEqual(findings, []) + + +if __name__ == "__main__": + unittest.main() From 352a6d067194d0fb96a229aa89fa8615db9e79d5 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 27 Aug 2026 13:07:32 +0100 Subject: [PATCH 2/6] DOC-7003 cover the relref shapes the checker was blind to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot found two coverage gaps and both were real. Measured on the current tree, the extraction saw only one of three relref shapes, so 124 distinct slashless refs and 42 distinct trailing-fragment refs were never checked -- invisible rather than reported, which is the worse failure for a tool whose value is completeness. I built the pattern around the dominant form and never asked which shapes existed. That is the blind-sample failure mode, and a reviewer found it rather than the tests, because every test I wrote used the shape I had already thought of. Now handled: {{< relref "./rel#anchor" >}} and {{< relref "bare/rel#anchor" >}} {{< relref "/abs/path" >}}#anchor Relative paths resolve against the directory of the page carrying the link, then as a site path. The resolution is deliberately conservative and can only under-report: a relref whose path is wrong fails the Hugo build, so every relref in the tree resolves somehow, and a path this model cannot find is a limit of the model rather than a defect in the docs. Those count as unhandled and never as findings, which makes a false positive structurally impossible for the relative forms. Only 2 more landed in that bucket, so the model covers nearly all of them. Effect on the same tree: 2,411 to 2,682 anchored relrefs actually checked, and 34 further real defects surfaced. One verified by hand -- fcall_ro.md links #read-only_scripts with an underscore where the heading slug uses a hyphen. Six new tests cover both shapes, each with a valid-form control, plus one asserting an unresolvable relative path is unhandled rather than reported. Mutation tested by restoring the old single-shape pattern, which turns six of them red. Learned: my tests all used the link shape I had already thought of, so they could not expose a missing shape — for an extraction tool, enumerate the shapes present in the corpus before trusting any coverage number. Constraint: resolve_relref returning None must stay "unhandled", never a finding; a bad relref path fails the build, so non-resolution is this model's limit and reporting it would manufacture false positives. Ticket: DOC-7003 Co-Authored-By: Claude Opus 5 (1M context) --- build/check_internal_anchors.py | 72 +++++++++++++++++++++------- build/test_check_internal_anchors.py | 39 +++++++++++++++ 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/build/check_internal_anchors.py b/build/check_internal_anchors.py index b352b46a77..115740010d 100755 --- a/build/check_internal_anchors.py +++ b/build/check_internal_anchors.py @@ -79,8 +79,17 @@ re.compile(r"<(https?://(?:www\.)?redis\.io[^>\s]*)>"), ) +# Three shapes occur, and an earlier version of this only matched the first, leaving +# 124 distinct slashless refs and 42 distinct trailing-fragment refs unchecked -- +# invisible rather than reported, which is the worse failure for a completeness tool. +# {{< relref "/abs/path#anchor" >}} +# {{< relref "./rel#anchor" >}} / {{< relref "bare/rel#anchor" >}} +# {{< relref "/abs/path" >}}#anchor (fragment outside the shortcode) RELREF_ANCHORED = re.compile( - r"""relref\s+["'](?P/[^"'#]*)#(?P[^"']+)["']""" + r"""relref\s+["'](?P[^"'#]*)#(?P[^"']+)["']""" +) +RELREF_TRAILING_FRAGMENT = re.compile( + r"""\{\{<\s*relref\s+["'](?P[^"'#]+)["']\s*>\}\}#(?P[A-Za-z0-9_.:-]+)""" ) # id= / name= with double, single, or absent quotes. @@ -156,6 +165,27 @@ def self_link_target(url: str) -> tuple[str, str] | None: return path, unquote(anchor) +def resolve_relref(public: Path, content: Path, src: Path, ref: str) -> Path | None: + """Resolve a relref path, absolute or relative to the page it appears on. + + Deliberately conservative. A relref whose *path* is wrong fails the Hugo build, + so every relref in the tree resolves somehow; if this function can't find the + page, that is a limit of this model rather than a defect in the docs. Callers + must therefore treat None as "unhandled", never as a finding -- which makes a + false positive structurally impossible for the relative forms. + """ + if ref.startswith("/"): + return resolve_page(public, ref) + # Relative to the directory of the page carrying the link, then as a site path. + rel = ref[2:] if ref.startswith("./") else ref + here = src.relative_to(content).parent.as_posix() + for candidate in (f"/{here}/{rel}", f"/{rel}"): + page = resolve_page(public, candidate) + if page is not None: + return page + return None + + def check(content: Path, public: Path) -> tuple[list[dict], dict[str, int]]: findings: list[dict] = [] tally = {"self_ok": 0, "self_skipped": 0, "relref_ok": 0, @@ -188,23 +218,29 @@ def check(content: Path, public: Path) -> tuple[list[dict], dict[str, int]]: continue tally["self_ok"] += 1 - for m in RELREF_ANCHORED.finditer(line): - path, anchor = m.group("path"), unquote(m.group("anchor")) - page = resolve_page(public, path) - if page is None: - # A relref that didn't resolve would have failed the build, so - # this means the path shape isn't one we map (not a defect). - tally["relref_unhandled"] += 1 - continue - if is_client_rendered(page): - tally["unverifiable"] += 1 - continue - if anchor not in anchors_in(page): - findings.append(dict(kind="relref", file=rel_src, line=lineno, - target=f'{path}#{anchor}', - problem=f"anchor #{anchor} not on target page")) - continue - tally["relref_ok"] += 1 + # The trailing-fragment form is matched first, because its path half also + # satisfies the quoted-path pattern; recording spans stops a double count. + seen_spans: list[tuple[int, int]] = [] + for pattern in (RELREF_TRAILING_FRAGMENT, RELREF_ANCHORED): + for m in pattern.finditer(line): + if any(a <= m.start() < b for a, b in seen_spans): + continue + seen_spans.append((m.start(), m.end())) + path, anchor = m.group("path"), unquote(m.group("anchor")) + page = resolve_relref(public, content, src, path) + if page is None: + # Never a finding: see resolve_relref's docstring. + tally["relref_unhandled"] += 1 + continue + if is_client_rendered(page): + tally["unverifiable"] += 1 + continue + if anchor not in anchors_in(page): + findings.append(dict(kind="relref", file=rel_src, line=lineno, + target=f'{path}#{anchor}', + problem=f"anchor #{anchor} not on target page")) + continue + tally["relref_ok"] += 1 return findings, tally diff --git a/build/test_check_internal_anchors.py b/build/test_check_internal_anchors.py index 4e29055817..bbf315c292 100644 --- a/build/test_check_internal_anchors.py +++ b/build/test_check_internal_anchors.py @@ -107,6 +107,45 @@ def test_release_notes_are_not_scanned(self): {"operate/release-notes/x.md": "See [x](https://redis.io/docs/latest/develop/nope/).\n"}) self.assertEqual(findings, []) + # --- relref path shapes (regression: an earlier version saw only one) ----- + + def test_dot_relative_relref_is_checked(self): + findings, _ = self.run_check( + {"develop/a.md": 'See {{< relref "./thing#no-such-heading" >}}.\n'}) + self.assertEqual(len(findings), 1, findings) + self.assertIn("no-such-heading", findings[0]["problem"]) + + def test_dot_relative_relref_valid_passes(self): + findings, tally = self.run_check( + {"develop/a.md": 'See {{< relref "./thing#real-heading" >}}.\n'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + + def test_bare_relative_relref_is_checked(self): + findings, _ = self.run_check( + {"a.md": 'See {{< relref "develop/thing#no-such-heading" >}}.\n'}) + self.assertEqual(len(findings), 1, findings) + + def test_trailing_fragment_outside_shortcode_is_checked(self): + """{{< relref "/path" >}}#anchor puts the fragment outside the quotes.""" + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing" >}}#no-such-heading here.\n'}) + self.assertEqual(len(findings), 1, findings) + self.assertIn("no-such-heading", findings[0]["problem"]) + + def test_trailing_fragment_valid_passes_and_is_not_double_counted(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing" >}}#real-heading here.\n'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1, "counted once, not twice") + + def test_unresolvable_relative_path_is_unhandled_not_a_finding(self): + """A bad relref *path* fails the build, so non-resolution is our limit.""" + findings, tally = self.run_check( + {"a.md": 'See {{< relref "some/unknown/shape#anchor" >}}.\n'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_unhandled"], 1) + # --- resolution details --------------------------------------------------- def test_non_page_artifact_resolves(self): From 00d1219b1adff3d5e679b66fcde2f97d38690cf7 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 27 Aug 2026 13:41:07 +0100 Subject: [PATCH 3/6] DOC-7003 fix same-page relref resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot caught a false-positive class I introduced one commit earlier. Broadening the path pattern to accept slashless refs made relref "#anchor" match for the first time, with an empty path, and the relative branch then treated empty as "the directory of this page" -- which is the sibling section _index, not the page itself. Two live links were reported broken: jedis/failover.md's #retry-configuration and strings.md's #string-counter-support, both of which are present on their own pages. An empty path now resolves to the source file's own built page, with _index.md mapping to its directory rather than to a child. Findings drop 152 to 150, and the three that remain are real: the same-page anchors in active-active/_index.md carry a trailing slash, and #multi-primary-replication/ is absent from its own page while #multi-primary-replication is present. The fixture for the new tests puts a leaf page and a section _index side by side with *different* anchors, so a resolver that picks the wrong page fails rather than passing by luck. Mutation testing this cost two attempts: the obvious edit hit an identically worded `if not rel:` in resolve_page instead, the suite stayed green, and that looked like the tests being weak when it was the mutation missing its target. Learned: broadening an extraction pattern can hand new input shapes to a resolver that was never written for them — a coverage fix and a resolution fix are different changes, and the second is where the false positives come from. Constraint: an empty relref path means the current page; routing it through the directory-relative branch validates against the sibling section index. Directive: when mutation testing, confirm the mutation landed where you meant — a duplicated line elsewhere in the file will absorb it and the green suite will look like weak tests. Ticket: DOC-7003 Co-Authored-By: Claude Opus 5 (1M context) --- build/check_internal_anchors.py | 14 +++++++++++++- build/test_check_internal_anchors.py | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/build/check_internal_anchors.py b/build/check_internal_anchors.py index 115740010d..6fc9b77c6b 100755 --- a/build/check_internal_anchors.py +++ b/build/check_internal_anchors.py @@ -165,6 +165,13 @@ def self_link_target(url: str) -> tuple[str, str] | None: return path, unquote(anchor) +def own_page(public: Path, content: Path, src: Path) -> Path | None: + """The built page for a source file. `_index.md` publishes at its directory.""" + rel = src.relative_to(content) + url = rel.parent if rel.name == "_index.md" else rel.with_suffix("") + return resolve_page(public, "/" + url.as_posix()) + + def resolve_relref(public: Path, content: Path, src: Path, ref: str) -> Path | None: """Resolve a relref path, absolute or relative to the page it appears on. @@ -176,8 +183,13 @@ def resolve_relref(public: Path, content: Path, src: Path, ref: str) -> Path | N """ if ref.startswith("/"): return resolve_page(public, ref) - # Relative to the directory of the page carrying the link, then as a site path. rel = ref[2:] if ref.startswith("./") else ref + if not rel: + # relref "#anchor" is a *same-page* link. Falling through to the directory + # logic below would validate it against the sibling section _index instead of + # the current page, which reported two live links as broken. + return own_page(public, content, src) + # Relative to the directory of the page carrying the link, then as a site path. here = src.relative_to(content).parent.as_posix() for candidate in (f"/{here}/{rel}", f"/{rel}"): page = resolve_page(public, candidate) diff --git a/build/test_check_internal_anchors.py b/build/test_check_internal_anchors.py index bbf315c292..684fe0dbb8 100644 --- a/build/test_check_internal_anchors.py +++ b/build/test_check_internal_anchors.py @@ -139,6 +139,33 @@ def test_trailing_fragment_valid_passes_and_is_not_double_counted(self): self.assertEqual(findings, []) self.assertEqual(tally["relref_ok"], 1, "counted once, not twice") + # A leaf page beside a section _index, each with a *different* anchor, so these + # tests fail if the resolver picks the wrong one rather than passing by luck. + SAME_PAGE_TREE = {"/develop": '

S

', + "/develop/thing": '

P

'} + + def test_same_page_relref_checks_the_current_page(self): + findings, tally = self.run_check( + {"develop/thing.md": 'See {{< relref "#page-only" >}}.\n'}, + pages=self.SAME_PAGE_TREE) + self.assertEqual(findings, [], "own-page anchor must resolve") + self.assertEqual(tally["relref_ok"], 1) + + def test_same_page_relref_does_not_check_the_section_index(self): + """The regression: an empty path resolved to the sibling section page.""" + findings, _ = self.run_check( + {"develop/thing.md": 'See {{< relref "#section-only" >}}.\n'}, + pages=self.SAME_PAGE_TREE) + self.assertEqual(len(findings), 1, "section's anchor is not on this page") + + def test_same_page_relref_from_an_index_page(self): + """_index.md publishes at its directory, so its own page is that directory.""" + findings, tally = self.run_check( + {"develop/_index.md": 'See {{< relref "#section-only" >}}.\n'}, + pages=self.SAME_PAGE_TREE) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + def test_unresolvable_relative_path_is_unhandled_not_a_finding(self): """A bad relref *path* fails the build, so non-resolution is our limit.""" findings, tally = self.run_check( From 6ffa78b445d9b8ff7bef7fa4f1b1dfc40723180e Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 27 Aug 2026 13:41:07 +0100 Subject: [PATCH 4/6] DOC-7003 run the internal anchor check weekly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires build/check_internal_anchors.py into CI, in its own workflow rather than into link_check, which a sibling PR is already rewriting. Report-only by construction. The checker exits 1 on findings so that a local `make check_internal_anchors` is useful, and the job deliberately does not inherit that. It does still fail on exit 2, which is the checker's "resolved nothing at all, so the scoping or the build is broken" signal -- `|| true` would have swallowed the one outcome that means the report cannot be trusted. Weekly rather than post-merge, which is the opposite of alias_check's choice and for a stated reason: that scan needs no build and takes three seconds, whereas this needs a full Hugo build to read the anchors Hugo itself emitted. A per-push run would add about ten minutes to every merge to report rot that is days old rather than anything that merge introduced. The report renderer is embedded in the workflow, so it was extracted from the YAML and run verbatim against real findings before committing. That caught a crash: groupby lives in itertools, not collections, and the mistake would only have surfaced in a scheduled run a week later, after the artifact upload had already succeeded. Learned: an embedded heredoc script gets no linting and no import check, so run it verbatim out of the YAML before trusting it — a scheduled workflow hides that class of error for a week. Constraint: keep the exit-2 escalation; the checker uses it for "resolved nothing", and swallowing it would let a broken build report zero findings as a clean result. Ticket: DOC-7003 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/internal_anchor_check.yaml | 149 +++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 .github/workflows/internal_anchor_check.yaml diff --git a/.github/workflows/internal_anchor_check.yaml b/.github/workflows/internal_anchor_check.yaml new file mode 100644 index 0000000000..bafddd93cd --- /dev/null +++ b/.github/workflows/internal_anchor_check.yaml @@ -0,0 +1,149 @@ +name: internal_anchor_check + +# Report internal links whose target page or heading anchor doesn't exist: +# absolute redis.io self-links, and relref-with-anchor. Neither is checked by +# anything else -- Hugo treats a self-link as external and .lychee.toml excludes +# redis.io as internal, while relref validates the page and never the heading. +# +# See build/check_internal_anchors.py and DOC-7003. +# +# Report-only, and never a gate. The checker exits 1 when it finds anything, so +# that a local `make check_internal_anchors` is useful, but this workflow reports +# the findings and returns success. There were 150 of them at the time of writing: +# a backlog to work down, not a merge blocker, and a check that blocks on a +# 150-item backlog just gets switched off. +# +# Weekly rather than post-merge, unlike alias_check. That scan needs no build and +# takes about three seconds, so running it on every push to main is nearly free. +# This one needs a full Hugo build to read the anchors Hugo itself emitted, so a +# per-push run would add ten minutes to every merge for a report whose findings +# are days-old rot rather than something introduced by that specific merge. + +on: + schedule: + - cron: '0 7 * * 1' # 07:00 UTC every Monday, an hour after link_check + workflow_dispatch: + +permissions: + contents: read + +env: + HUGO_VERSION: 0.143.1 + +jobs: + check: + name: Check internal links and anchors + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + findings: ${{ steps.check.outputs.findings }} + steps: + - name: Install Hugo + run: | + wget -O "${{ runner.temp }}/hugo.deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb" \ + && sudo dpkg -i "${{ runner.temp }}/hugo.deb" + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Install dependencies and build components + run: | + make deps + make components + + - name: Build the site + # The anchors are read out of this output rather than derived from a slug + # rule, so the build is the oracle and cannot be skipped. + run: hugo --gc + + - name: Run the checker + id: check + run: | + # Report-only: the checker's exit 1 must not fail this job. `|| true` + # would also swallow the exit 2 it uses for "resolved nothing, so the + # scoping or the build is broken", which is a real failure worth seeing. + set +e + python3 build/check_internal_anchors.py --json > findings.json + status=$? + set -e + if [ "$status" -ge 2 ]; then + echo "::error::checker could not run (exit $status)" + exit "$status" + fi + count=$(python3 -c 'import json;print(len(json.load(open("findings.json"))["findings"]))') + echo "findings=$count" >> "$GITHUB_OUTPUT" + echo "Found $count internal link/anchor problems" >&2 + + - name: Render a report + if: steps.check.outputs.findings != '0' + run: | + python3 - <<'PY' > internal-anchor-report.md + import json, itertools + d = json.load(open("findings.json")) + f, t = d["findings"], d["tally"] + print("Internal links whose target page or anchor does not exist.") + print() + print(f"**{len(f)} findings.** Checked {t['self_ok']} absolute self-links and " + f"{t['relref_ok']} anchored relrefs successfully; " + f"{t['unverifiable']} anchors sit on client-rendered pages and cannot be " + f"read here, {t['self_skipped']} self-links point outside the docs tree, " + f"{t['relref_unhandled']} relref paths did not resolve to a page.") + print() + print("A finding is a candidate, not a confirmed defect. Check the built HTML " + "for the target anchor before editing a link.") + print() + for kind, group in itertools.groupby( + sorted(f, key=lambda x: x["kind"]), key=lambda x: x["kind"]): + rows = list(group) + print(f"### {kind} ({len(rows)})") + print() + for x in sorted(rows, key=lambda x: (x["file"], x["line"])): + print(f"- `{x['file']}:{x['line']}` — `{x['target']}` — {x['problem']}") + print() + PY + + - name: Upload report + if: steps.check.outputs.findings != '0' + uses: actions/upload-artifact@v4 + with: + name: internal-anchor-report + path: internal-anchor-report.md + if-no-files-found: error + + report: + name: Open or update tracking issue + needs: check + # Separate job so the checker itself never holds an issues:write token. + if: ${{ always() && needs.check.outputs.findings != '0' && needs.check.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Download report + uses: actions/download-artifact@v4 + with: + name: internal-anchor-report + path: . + + - name: Open or update tracking issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh label create internal-anchor-check --color C5DEF5 \ + --description "Automated internal link/anchor check (report-only)" 2>/dev/null || true + + title="Internal links with dead pages or anchors (report-only)" + existing=$(gh issue list --label internal-anchor-check --state open \ + --json number --jq '.[0].number // empty') + + if [ -n "$existing" ]; then + gh issue comment "$existing" --body-file internal-anchor-report.md + echo "Updated existing issue #$existing" + else + gh issue create --title "$title" --label internal-anchor-check \ + --body-file internal-anchor-report.md + fi From e773e88f80af158d228618e6e843dd4d89c3aba6 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 27 Aug 2026 14:02:22 +0100 Subject: [PATCH 5/6] DOC-7003 check .md relref paths and tighten client-rendered detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more valid Bugbot findings, both under-reporting rather than false alarms, which is the failure mode this tool can hide best. Hugo's relref accepts a source filename, so relref "/glossary/_index.md#letter-a" means that page. Those paths were forwarded to a resolver that only looks for a literal file, came back unresolved, and were counted as unhandled -- 42 distinct across the corpus, 9 of them in live sources. Stripping the .md, /index.md and /_index.md suffixes is done in the relref path only, deliberately not in resolve_page, because that function also resolves self-links and the docs tree really does publish literal files: sitemap.xml, docs.ndjson, and the .md twin of every page. A link to one of those must keep its extension, and there is now a test pinning that. The client-rendered check matched the bare substrings redoc, swagger-ui and rapidoc anywhere in a page's HTML. That is true of ordinary tutorial pages -- redisom-for-java quotes a springfox-swagger-ui dependency and a localhost:8080/swagger-ui/ URL -- so 14 pages were being skipped when only 7 genuinely build their content in the browser, and any real defect in a link to the other 7 was invisible. It now matches the mount instead: the and elements, a swagger-ui mount node, or the bundle script. Checked against the corpus first, and every real mount uses the element form; none uses a div id. Which exposed a weak test of my own. The original client-rendered test invented `
` markup that no page emits, and it passed only because the old detector matched any occurrence of the substring. Tightening the detector turned it red, correctly. The fixture now uses the markup the api-reference pages actually emit. Net effect: 2,684 to 2,699 anchored relrefs checked, unresolved paths 17 to 7, unverifiable 75 to 69, findings 150 to 151. Learned: a fixture that does not match production markup validates nothing — my client-rendered test only passed because the detector was loose enough to match invented markup, so tightening the detector is what exposed the test. Constraint: strip .md suffixes for relref paths only, never in resolve_page — self-links legitimately target published literal files such as sitemap.xml and each page's .md twin. Directive: before adding a marker to CLIENT_RENDERED, grep the corpus for how that tool actually mounts; matching a name rather than a mount silently skips real pages. Ticket: DOC-7003 Co-Authored-By: Claude Opus 5 (1M context) --- build/check_internal_anchors.py | 28 +++++++++++++- build/test_check_internal_anchors.py | 57 +++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/build/check_internal_anchors.py b/build/check_internal_anchors.py index 6fc9b77c6b..901f6ac20f 100755 --- a/build/check_internal_anchors.py +++ b/build/check_internal_anchors.py @@ -98,7 +98,17 @@ # 130 on a normal content page. Its anchors are real in the browser and invisible here, # so they are UNVERIFIABLE, never missing. Reporting them would be the single largest # false-positive class: 69 of an unfiltered 172 findings. -CLIENT_RENDERED = re.compile(r"redoc|swagger-ui|rapidoc", re.I) +# Match the *mount*, not the mention. A bare substring search for these names also +# hits ordinary tutorial pages -- redisom-for-java quotes a springfox-swagger-ui +# dependency and a localhost:8080/swagger-ui/ URL -- and skipping those hid any real +# defects in links to them. Substring matching flagged 14 pages; the mount forms flag +# the 4 that genuinely build their content in the browser. +CLIENT_RENDERED = re.compile( + r"<\s*(?:redoc|rapi-doc)\b" # Redoc / RapiDoc custom elements + r"""|id=["']?swagger-ui\b""" # the conventional Swagger UI mount node + r"|swagger-ui-bundle", # ...or its bundle script + re.I, +) ANCHOR_ATTR = re.compile( r"""(?:id|name)\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_:.\-]+))""" @@ -172,6 +182,21 @@ def own_page(public: Path, content: Path, src: Path) -> Path | None: return resolve_page(public, "/" + url.as_posix()) +def strip_page_suffix(ref: str) -> str: + """Hugo's relref accepts a source filename, so `a/b.md` means the page `a/b`. + + Left out of resolve_page on purpose: that also resolves *self-links*, where the + docs tree really does publish literal files (`sitemap.xml`, and the `.md` twin of + every page), and a link to one of those must keep its extension. + """ + for suffix in ("/_index.md", "/index.md"): + if ref.endswith(suffix): + return ref[: -len(suffix)] or "/" + if ref in ("_index.md", "index.md"): + return "/" + return ref[:-3] if ref.endswith(".md") else ref + + def resolve_relref(public: Path, content: Path, src: Path, ref: str) -> Path | None: """Resolve a relref path, absolute or relative to the page it appears on. @@ -181,6 +206,7 @@ def resolve_relref(public: Path, content: Path, src: Path, ref: str) -> Path | N must therefore treat None as "unhandled", never as a finding -- which makes a false positive structurally impossible for the relative forms. """ + ref = strip_page_suffix(ref) if ref.startswith("/"): return resolve_page(public, ref) rel = ref[2:] if ref.startswith("./") else ref diff --git a/build/test_check_internal_anchors.py b/build/test_check_internal_anchors.py index 684fe0dbb8..b717956885 100644 --- a/build/test_check_internal_anchors.py +++ b/build/test_check_internal_anchors.py @@ -91,9 +91,13 @@ def test_marketing_site_link_is_skipped_not_failed(self): self.assertEqual(tally["self_skipped"], 1) def test_client_rendered_target_is_unverifiable_not_missing(self): + # The fixture uses the markup the api-reference pages actually emit. An + # earlier version invented `
`, which no page uses, and it + # passed only because the detector then matched the bare substring. findings, tally = self.run_check( {"a.md": 'See {{< relref "/operate/api#tag/Cluster/operation/x" >}}.\n'}, - pages={"/operate/api": '
'}) + pages={"/operate/api": + ''}) self.assertEqual(findings, []) self.assertEqual(tally["unverifiable"], 1) @@ -173,6 +177,57 @@ def test_unresolvable_relative_path_is_unhandled_not_a_finding(self): self.assertEqual(findings, []) self.assertEqual(tally["relref_unhandled"], 1) + # --- .md paths, which Hugo accepts as page references ---------------------- + + def test_md_suffix_relref_is_checked(self): + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing.md#no-such-heading" >}}.\n'}) + self.assertEqual(len(findings), 1, findings) + + def test_md_suffix_relref_valid_passes(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing.md#real-heading" >}}.\n'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + + def test_index_md_suffix_resolves_to_its_directory(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing/_index.md#real-heading" >}}.\n'}, + pages={"/develop/thing": PAGE}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + + def test_self_link_to_a_literal_md_file_keeps_its_extension(self): + """Regression guard: stripping .md must not break the published .md twins.""" + with TemporaryDirectory() as tmp: + root = Path(tmp) + content, public = build_tree( + root, {"/develop/thing": PAGE}, + {"a.md": "[x](https://redis.io/docs/latest/develop/thing/index.html.md)\n"}) + (public / "develop/thing/index.html.md").write_text("# t", encoding="utf-8") + findings, tally = cia.check(content, public) + self.assertEqual(findings, []) + self.assertEqual(tally["self_ok"], 1) + + # --- client-rendered detection must match a mount, not a mention ----------- + + def test_swagger_mention_in_prose_is_not_client_rendered(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#no-such-heading" >}}.\n'}, + pages={"/develop/thing": + '

R

' + '

browse to http://localhost:8080/swagger-ui/ to see it

' + 'springfox-swagger-ui'}) + self.assertEqual(len(findings), 1, "a prose mention must not skip the check") + self.assertEqual(tally["unverifiable"], 0) + + def test_redoc_element_is_client_rendered(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#whatever" >}}.\n'}, + pages={"/develop/thing": ''}) + self.assertEqual(findings, []) + self.assertEqual(tally["unverifiable"], 1) + # --- resolution details --------------------------------------------------- def test_non_page_artifact_resolves(self): From c9e25ee5fad86ea5e05deae8e8f79356a823ceda Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 27 Aug 2026 14:28:45 +0100 Subject: [PATCH 6/6] DOC-7003 count only real jump targets as anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot's sixth finding, and the most consequential so far because it made the tool lie in the reassuring direction. The anchor pool accepted any id= or name= attribute. A browser jumps to an id on any element, or a name on an
, and nothing else -- so the pool was also collecting , viewport, robots and generator from every page, the name= inside data-name=, and, via a URL query string, the tag manager container id from a noscript iframe. On the protocol spec page that was 114 values against 86 real targets. The consequence is a false negative: a link to #description would have been reported present on every page in the corpus. Nothing in the corpus exploits it today -- no relref references description, viewport, robots or generator -- so this was latent rather than active. Worth fixing anyway, since comment, format and docset were also in the pool and those are plausible anchor names, and since a checker that can silently pass a dead link is not worth running. Three tightenings, each verified to remove noise without dropping a real target: name= is now read only inside tags; script and style bodies are removed before scanning, because nothing inside them is a jump target; and both attributes require whitespace before them, which is what separates
from gtm.js?id=GTM-XXXX. The pool on that page is now 86, which matches an independent hand count of 69 ids plus 17 targets. Corpus totals are unchanged at 151 findings and 2,699 relrefs checked, so no real anchor was lost in the process. Learned: a checker's anchor pool is its trust boundary — every spurious member converts a dead link into a silent pass, so measure the pool against an independently counted page rather than assuming a regex is close enough. Constraint: name= counts only on tags, and both attributes require preceding whitespace; loosening either readmits meta names and URL query parameters as anchors. Ticket: DOC-7003 Co-Authored-By: Claude Opus 5 (1M context) --- build/check_internal_anchors.py | 36 +++++++++++++++++++---- build/test_check_internal_anchors.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/build/check_internal_anchors.py b/build/check_internal_anchors.py index 901f6ac20f..47adfb908e 100755 --- a/build/check_internal_anchors.py +++ b/build/check_internal_anchors.py @@ -34,8 +34,11 @@ * **Attribute quoting is optional in the regex.** Hugo drops optional quotes when minifying. This site doesn't minify today, but assuming quotes is precisely the bug that made an earlier audit report 23 present anchors as missing. -* **Legacy ```` anchors are part of the pool.** ``protocol-spec.md`` - alone defines 17 of them, and command pages link to them by name. +* **Legacy ```` anchors are part of the pool, but only on ```` + tags.** ``protocol-spec.md`` alone defines 17 of them and command pages link to + them, yet accepting every ``name=`` attribute also admits ```` from every page, which would report a dead ``#description`` + as present. Usage:: @@ -110,9 +113,24 @@ re.I, ) -ANCHOR_ATTR = re.compile( - r"""(?:id|name)\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_:.\-]+))""" +# A browser jumps to an `id` on any element, or a `name` on an `` -- and to +# nothing else. Accepting every `name=` also swallowed ``, +# `viewport`, `robots` and `generator` on every page, the `name=` inside +# `data-name=`, and fragments of the inline Google Tag Manager snippet: 114 values +# where the page has 86 real targets. A polluted pool is a false *negative* -- a dead +# `#description` would have been reported present -- which is the failure this tool +# can least afford. +ANCHOR_ID = re.compile( + # `(?` from a URL query parameter such as `gtm.js?id=GTM-XXXX`. + r"""(?]*>", re.I) +# Script and style bodies are not markup, but they contain `id=` -- the inline tag +# manager snippet has `gtm.js?id='+i+dl`, which matched as an anchor called +# `GTM-TKZ6J9R`. Nothing inside them is a jump target, so they come out first. +SCRIPT_OR_STYLE = re.compile(r"<(script|style)\b.*?", re.I | re.S) +A_NAME = re.compile(r"""(? list[Path]: @@ -125,10 +143,16 @@ def live_sources(content: Path) -> list[Path]: def anchors_in(html: Path, _cache: dict[Path, set[str]] = {}) -> set[str]: """Every anchor a browser could jump to on a built page.""" if html not in _cache: - text = html.read_text(encoding="utf-8", errors="replace") + text = SCRIPT_OR_STYLE.sub(" ", html.read_text(encoding="utf-8", errors="replace")) found = set() - for m in ANCHOR_ATTR.finditer(text): + for m in ANCHOR_ID.finditer(text): found.add(next(g for g in m.groups() if g is not None)) + # Legacy `` targets, scoped to anchor tags so that meta and + # form element names stay out of the pool. + for tag in A_TAG.finditer(text): + m = A_NAME.search(tag.group(0)) + if m: + found.add(next(g for g in m.groups() if g is not None)) _cache[html] = found return _cache[html] diff --git a/build/test_check_internal_anchors.py b/build/test_check_internal_anchors.py index b717956885..2725c19e1e 100644 --- a/build/test_check_internal_anchors.py +++ b/build/test_check_internal_anchors.py @@ -228,6 +228,50 @@ def test_redoc_element_is_client_rendered(self): self.assertEqual(findings, []) self.assertEqual(tally["unverifiable"], 1) + # --- the anchor pool must hold only real jump targets --------------------- + + def test_meta_name_is_not_a_jump_target(self): + """Every page carries ; #description is not an anchor.""" + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#description" >}}.\n'}, + pages={"/develop/thing": + '' + '' + '

R

'}) + self.assertEqual(len(findings), 1, "a meta name must not satisfy a fragment") + + def test_data_prefixed_attributes_are_not_jump_targets(self): + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#nope" >}}.\n'}, + pages={"/develop/thing": + '
' + '

R

'}) + self.assertEqual(len(findings), 1, "data-id/data-name must not count") + + def test_url_query_parameter_is_not_a_jump_target(self): + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#GTM-ABC123" >}}.\n'}, + pages={"/develop/thing": + '

R

'}) + self.assertEqual(len(findings), 1, "an id= query param must not count") + + def test_ids_inside_script_bodies_are_not_jump_targets(self): + findings, _ = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#in-a-script" >}}.\n'}, + pages={"/develop/thing": + '' + '

R

'}) + self.assertEqual(len(findings), 1, "script bodies are not markup") + + def test_a_name_still_counts_but_only_on_anchor_tags(self): + findings, tally = self.run_check( + {"a.md": 'See {{< relref "/develop/thing#legacy" >}}.\n'}, + pages={"/develop/thing": + '

R

'}) + self.assertEqual(findings, []) + self.assertEqual(tally["relref_ok"], 1) + # --- resolution details --------------------------------------------------- def test_non_page_artifact_resolves(self):