CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
153 lines
4.8 KiB
Python
Executable File
153 lines
4.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
scripts/link-checker.py — Validate internal Markdown links across docs.
|
|
Part of the docs-audit tooling.
|
|
|
|
Scope (what IS checked):
|
|
- Relative file-existence of every `[text](path)` link in .md files
|
|
under the search root (excluding dotfiles and ai_analysis/).
|
|
- Skips links inside destructive-ignored paths (ai_analysis/, hidden).
|
|
|
|
Out of scope (what is NOT checked):
|
|
- External HTTP/HTTPS URLs — skipped entirely (see is_external_url).
|
|
- Anchor fragments (`#section-id`) — stripped before file-existence
|
|
check. A broken anchor inside a valid file will NOT be flagged.
|
|
See docs/known-issues.md for the rationale.
|
|
|
|
`--strict` flag:
|
|
- Changes OUTPUT FORMAT only: in strict mode no human-readable
|
|
preamble is printed; only CSV rows are emitted. The set of checks
|
|
performed is identical in both modes.
|
|
"""
|
|
|
|
import re
|
|
import os
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
import sys
|
|
|
|
# Regex to match Markdown links: [text](url)
|
|
MARKDOWN_LINK_PATTERN = r'\[([^\]]+)\]\(([^)]+)\)'
|
|
|
|
|
|
def is_external_url(url):
|
|
"""Check if a URL is external (http/https)."""
|
|
return url.startswith('http://') or url.startswith('https://')
|
|
|
|
|
|
def resolve_internal_link(doc_path, link_url):
|
|
"""Resolve a relative link from a document."""
|
|
# Remove anchors
|
|
link_path = link_url.split('#')[0]
|
|
|
|
if not link_path:
|
|
# Anchor-only link (same file)
|
|
return str(doc_path)
|
|
|
|
# If absolute (starts with /), resolve from repo root
|
|
if link_path.startswith('/'):
|
|
return link_path
|
|
|
|
# Relative to document
|
|
doc_dir = Path(doc_path).parent
|
|
resolved = (doc_dir / link_path).resolve()
|
|
return str(resolved)
|
|
|
|
|
|
def find_broken_links(search_root="."):
|
|
"""Scan all .md files for broken links."""
|
|
search_root = Path(search_root)
|
|
broken_links = []
|
|
|
|
for md_file in search_root.rglob("*.md"):
|
|
# Skip hidden and cache dirs
|
|
if any(part.startswith('.') for part in md_file.parts):
|
|
continue
|
|
# Skip legacy ai_analysis tree — being deleted by the agentic refactor
|
|
if 'ai_analysis' in md_file.parts:
|
|
continue
|
|
# Skip the upstream opencode submodule (MIT). Its .md files +
|
|
# bun node_modules tree are third-party and out of our scope.
|
|
if 'opencode' in md_file.parts or 'node_modules' in md_file.parts:
|
|
continue
|
|
|
|
try:
|
|
content = md_file.read_text(encoding='utf-8')
|
|
except Exception as e:
|
|
print(f"Warning: Could not read {md_file}: {e}", file=sys.stderr)
|
|
continue
|
|
|
|
for line_num, line in enumerate(content.split('\n'), 1):
|
|
for match in re.finditer(MARKDOWN_LINK_PATTERN, line):
|
|
text, link = match.groups()
|
|
|
|
# Skip external URLs (best-effort)
|
|
if is_external_url(link):
|
|
continue
|
|
|
|
# Validate internal link
|
|
link_path = link.split('#')[0] # Remove anchor
|
|
|
|
if not link_path:
|
|
# Anchor-only is OK
|
|
continue
|
|
|
|
# Resolve relative to document
|
|
if link_path.startswith('/'):
|
|
# Absolute path
|
|
abs_path = Path(search_root) / link_path.lstrip('/')
|
|
else:
|
|
# Relative path
|
|
abs_path = (md_file.parent / link_path).resolve()
|
|
|
|
# Check if file exists
|
|
if not abs_path.exists():
|
|
broken_links.append({
|
|
'file': str(md_file.relative_to(search_root)),
|
|
'line': line_num,
|
|
'link': link,
|
|
'text': text,
|
|
})
|
|
|
|
return broken_links
|
|
|
|
|
|
def print_csv(broken_links):
|
|
"""Print results as CSV."""
|
|
print("file,line,link,text")
|
|
for item in broken_links:
|
|
# Escape CSV
|
|
file_val = item['file'].replace('"', '""')
|
|
link_val = item['link'].replace('"', '""')
|
|
text_val = item['text'].replace('"', '""')
|
|
print(f'"{file_val}",{item["line"]},"{link_val}","{text_val}"')
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
# Parse args: optional directory + --strict flag
|
|
strict = False
|
|
args = [a for a in sys.argv[1:] if a != '--strict']
|
|
if '--strict' in sys.argv:
|
|
strict = True
|
|
search_root = args[0] if args else "."
|
|
|
|
broken_links = find_broken_links(search_root)
|
|
|
|
if broken_links:
|
|
if strict:
|
|
# Strict mode — emit only CSV rows, no preamble
|
|
print_csv(broken_links)
|
|
else:
|
|
print(f"Found {len(broken_links)} broken internal links:\n")
|
|
print_csv(broken_links)
|
|
return 1
|
|
else:
|
|
if not strict:
|
|
print("✓ All internal links validated")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|