From d830918fd10e099f676ac122047611fce2a3c2ef Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 08:17:59 +0000 Subject: [PATCH] ci(docs): add markdown relative link check gate --- .github/workflows/ci.yml | 5 ++ Makefile | 5 ++ scripts/check_markdown_links.py | 92 +++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100755 scripts/check_markdown_links.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93832f81..05ee0998 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,11 @@ jobs: make frontend-test make frontend-build + + - name: Docs link check + run: | + python scripts/check_markdown_links.py + - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/Makefile b/Makefile index 694edd15..1cbb6f2e 100644 --- a/Makefile +++ b/Makefile @@ -122,3 +122,8 @@ backend-templates-sync: ## Sync templates to existing gateway agents (usage: mak .PHONY: check check: lint typecheck backend-coverage frontend-test build ## Run lint + typecheck + tests + coverage + build + + +.PHONY: docs-link-check +docs-link-check: ## Check for broken relative links in markdown docs + python scripts/check_markdown_links.py diff --git a/scripts/check_markdown_links.py b/scripts/check_markdown_links.py new file mode 100755 index 00000000..cca1accd --- /dev/null +++ b/scripts/check_markdown_links.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Lightweight markdown link checker for repo docs. + +Checks *relative* links inside markdown files and fails CI if any targets are missing. + +Design goals: +- No external deps. +- Ignore http(s)/mailto links. +- Ignore pure anchors (#foo). +- Support links with anchors (./path.md#section) by checking only the path part. + +Limitations: +- Does not validate that anchors exist inside target files. +- Does not validate links generated dynamically or via HTML. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)") + + +def iter_md_files(root: Path) -> list[Path]: + patterns = [ + root / "README.md", + root / "CONTRIBUTING.md", + ] + files: list[Path] = [] + for p in patterns: + if p.exists(): + files.append(p) + docs = root / "docs" + if docs.exists(): + files.extend(sorted(docs.rglob("*.md"))) + return files + + +def normalize_target(raw: str) -> str | None: + raw = raw.strip() + if not raw: + return None + if raw.startswith("http://") or raw.startswith("https://") or raw.startswith("mailto:"): + return None + if raw.startswith("#"): + return None + # strip query/fragment + raw = raw.split("#", 1)[0].split("?", 1)[0] + if not raw: + return None + return raw + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + md_files = iter_md_files(root) + + missing: list[tuple[Path, str]] = [] + + for md in md_files: + text = md.read_text(encoding="utf-8") + for m in LINK_RE.finditer(text): + target_raw = m.group(1) + target = normalize_target(target_raw) + if target is None: + continue + + # Skip common markdown reference-style quirks. + if target.startswith("<") and target.endswith(">"): + continue + + # Resolve relative to current file. + resolved = (md.parent / target).resolve() + if not resolved.exists(): + missing.append((md, target_raw)) + + if missing: + print("Broken relative links detected:\n") + for md, target in missing: + print(f"- {md.relative_to(root)} -> {target}") + print(f"\nTotal: {len(missing)}") + return 1 + + print(f"OK: checked {len(md_files)} markdown files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())