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 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..47adfb908e --- /dev/null +++ b/build/check_internal_anchors.py @@ -0,0 +1,349 @@ +"""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, 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:: + + 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]*)>"), +) + +# 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[^"']+)["']""" +) +RELREF_TRAILING_FRAGMENT = re.compile( + r"""\{\{<\s*relref\s+["'](?P[^"'#]+)["']\s*>\}\}#(?P[A-Za-z0-9_.:-]+)""" +) + +# 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. +# 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, +) + +# 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]: + """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 = SCRIPT_OR_STYLE.sub(" ", html.read_text(encoding="utf-8", errors="replace")) + found = set() + 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] + + +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 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 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. + + 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. + """ + ref = strip_page_suffix(ref) + if ref.startswith("/"): + return resolve_page(public, ref) + 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) + 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, + "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 + + # 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 + + +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..2725c19e1e --- /dev/null +++ b/build/test_check_internal_anchors.py @@ -0,0 +1,305 @@ +"""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): + # 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": + ''}) + 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, []) + + # --- 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") + + # 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( + {"a.md": 'See {{< relref "some/unknown/shape#anchor" >}}.\n'}) + 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) + + # --- 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): + """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()