check_doc_links.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. #!/usr/bin/env python3
  2. """Local documentation link checker for d-OAuth-Server.
  3. This is the "markdown-link-check" equivalent named by task 48. It validates
  4. every relative Markdown link in the root README.md, root AGENTS.md, and the
  5. docs/*.md guides, so CI fails when a doc points at a file or heading that no
  6. longer exists. External http(s), mailto:, and tel: targets are ignored: CI has
  7. no network dependency and the RFC references are stable public URLs.
  8. Usage:
  9. python3 scripts/check_doc_links.py # scan the default set
  10. python3 scripts/check_doc_links.py README.md # scan explicit files
  11. Exit code 0 when every relative link resolves, 1 otherwise.
  12. """
  13. from __future__ import annotations
  14. import re
  15. import sys
  16. from pathlib import Path
  17. # Inline Markdown links only: [text](target). The docs use no reference-style
  18. # links, so a simpler parser is enough and avoids a Markdown dependency.
  19. LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
  20. HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
  21. EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
  22. REPO_ROOT = Path(__file__).resolve().parent.parent
  23. DEFAULT_FILES = [REPO_ROOT / "README.md", REPO_ROOT / "AGENTS.md"]
  24. DEFAULT_FILES += sorted((REPO_ROOT / "docs").glob("*.md"))
  25. def slugify(text: str) -> str:
  26. """GitHub-style heading slug: lowercase, drop punctuation, spaces to '-'.
  27. Fenced code blocks are stripped by the caller, so inline code backticks
  28. may still appear; they are removed here. Trailing duplicate-numbering
  29. suffixes are not modeled because the docs use unique headings.
  30. """
  31. text = text.replace("`", "")
  32. text = re.sub(r"[^\w\s-]", "", text.lower())
  33. return re.sub(r"\s+", "-", text).strip("-")
  34. def headings_of(path: Path) -> set[str]:
  35. """Every heading slug in a Markdown file, ignoring fenced code blocks."""
  36. slugs: set[str] = set()
  37. in_fence = False
  38. for line in path.read_text(encoding="utf-8").splitlines():
  39. stripped = line.lstrip()
  40. if stripped.startswith("```"):
  41. in_fence = not in_fence
  42. continue
  43. if in_fence:
  44. continue
  45. match = HEADING_RE.match(line)
  46. if match:
  47. slugs.add(slugify(match.group(2)))
  48. return slugs
  49. def check_file(path: Path, problems: list[str]) -> None:
  50. if not path.exists():
  51. problems.append(f"missing doc: {path.relative_to(REPO_ROOT)}")
  52. return
  53. text = path.read_text(encoding="utf-8")
  54. for target in LINK_RE.findall(text):
  55. if target.startswith(EXTERNAL_PREFIXES):
  56. continue
  57. if target.startswith("#"):
  58. anchor = target[1:]
  59. if anchor and slugify(anchor) not in headings_of(path):
  60. problems.append(f"{path.relative_to(REPO_ROOT)}: missing anchor #{anchor}")
  61. continue
  62. file_part, _, anchor = target.partition("#")
  63. resolved = (path.parent / file_part).resolve()
  64. if not resolved.exists():
  65. problems.append(f"{path.relative_to(REPO_ROOT)}: broken link -> {target}")
  66. continue
  67. if anchor and resolved.suffix == ".md":
  68. if slugify(anchor) not in headings_of(resolved):
  69. problems.append(
  70. f"{path.relative_to(REPO_ROOT)}: missing anchor #{anchor} in {file_part}"
  71. )
  72. def main(argv: list[str]) -> int:
  73. files = [Path(a) for a in argv[1:]] if len(argv) > 1 else DEFAULT_FILES
  74. problems: list[str] = []
  75. for path in files:
  76. check_file(path if path.is_absolute() else REPO_ROOT / path, problems)
  77. if problems:
  78. print("doc link check FAILED:")
  79. for problem in problems:
  80. print(f" - {problem}")
  81. return 1
  82. print(f"doc link check passed: {len(files)} file(s) scanned")
  83. return 0
  84. if __name__ == "__main__":
  85. sys.exit(main(sys.argv))