Files
2026-05-05 03:48:41 +08:00

182 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Check metadata path references resolve to real repository paths and anchors."""
from __future__ import annotations
import re
import sys
import urllib.parse
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
METADATA_FILES = [
Path("metadata/taxonomy.yml"),
Path("metadata/redirects.yml"),
]
def github_slug(title: str) -> str:
title = re.sub(r"<[^>]+>", "", title.strip().lower())
title = re.sub(r"[`*_~]", "", title)
title = re.sub(r"[^\w\u4e00-\u9fff\- ]+", "", title)
title = re.sub(r"\s+", "-", title).strip("-")
return title
def markdown_anchors(path: Path) -> set[str]:
text = path.read_text(encoding="utf-8", errors="ignore")
anchors = set(re.findall(r"<a\s+id=[\"']([^\"']+)[\"']", text))
used: dict[str, int] = {}
in_fence = False
fence_marker = ""
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith(("```", "~~~")):
marker = stripped[:3]
if not in_fence:
in_fence = True
fence_marker = marker
elif marker == fence_marker:
in_fence = False
fence_marker = ""
continue
if in_fence:
continue
heading = re.match(r"^(#{1,6})\s+(.+?)\s*#*\s*$", line)
if not heading:
continue
slug = github_slug(heading.group(2))
if not slug:
continue
count = used.get(slug, 0)
used[slug] = count + 1
anchors.add(slug if count == 0 else f"{slug}-{count}")
return anchors
def metadata_targets(path: Path) -> list[tuple[int, str]]:
targets: list[tuple[int, str]] = []
text = path.read_text(encoding="utf-8", errors="ignore")
for lineno, line in enumerate(text.splitlines(), start=1):
stripped = line.strip()
if stripped.startswith(("path:", "entry:", "agent_guide:", "to:")):
_, value = stripped.split(":", 1)
targets.append((lineno, value.strip().strip("\"'")))
continue
if stripped.startswith("- "):
value = stripped[2:].strip().strip("\"'")
if value.startswith("from:"):
continue
if value.startswith(("path:", "entry:", "agent_guide:", "to:")):
_, value = value.split(":", 1)
targets.append((lineno, value.strip().strip("\"'")))
continue
if value.startswith((".", "/")) or "/" in value or value.endswith(".md"):
targets.append((lineno, value))
return targets
def redirect_pairs(path: Path) -> list[tuple[int, str, str]]:
pairs: list[tuple[int, str, str]] = []
current_from: tuple[int, str] | None = None
for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), start=1):
stripped = line.strip()
if stripped.startswith("- from:"):
_, value = stripped.split(":", 1)
current_from = (lineno, value.strip().strip("\"'"))
continue
if stripped.startswith("to:") and current_from:
_, value = stripped.split(":", 1)
pairs.append((current_from[0], current_from[1], value.strip().strip("\"'")))
current_from = None
return pairs
def validate_redirects(path: Path) -> list[str]:
errors: list[str] = []
seen_from: dict[str, int] = {}
for lineno, source, target in redirect_pairs(path):
normalized_source = source.rstrip("/")
normalized_target = target.split("#", 1)[0].rstrip("/")
if normalized_source == normalized_target:
errors.append(f"{path}:{lineno}: no-op redirect maps '{source}' to itself")
if source in seen_from:
errors.append(f"{path}:{lineno}: duplicate redirect source '{source}', first seen at line {seen_from[source]}")
else:
seen_from[source] = lineno
source_path = ROOT / source
if source_path.exists():
errors.append(f"{path}:{lineno}: redirect source still exists in repository: {source}")
return errors
def validate_target(source: Path, lineno: int, raw: str, anchor_cache: dict[Path, set[str]]) -> str | None:
if not raw or raw.startswith(("http://", "https://", "mailto:", "tel:")):
return None
if " 或 " in raw:
return f"{source}:{lineno}: ambiguous metadata target: {raw}"
target = urllib.parse.unquote(raw.strip("<>"))
path_part, _, anchor = target.partition("#")
destination = (ROOT / path_part).resolve()
try:
destination.relative_to(ROOT)
except ValueError:
return None
if not destination.exists():
return f"{source}:{lineno}: missing metadata target: {raw}"
if anchor and destination.suffix.lower() == ".md":
if destination not in anchor_cache:
anchor_cache[destination] = markdown_anchors(destination)
if anchor not in anchor_cache[destination]:
rel = destination.relative_to(ROOT)
return f"{source}:{lineno}: missing metadata anchor: {rel}#{anchor}"
return None
def main() -> int:
errors: list[str] = []
anchor_cache: dict[Path, set[str]] = {}
for rel_path in METADATA_FILES:
path = ROOT / rel_path
if not path.exists():
errors.append(f"{rel_path}: missing metadata file")
continue
for lineno, target in metadata_targets(path):
error = validate_target(rel_path, lineno, target, anchor_cache)
if error:
errors.append(error)
if rel_path == Path("metadata/redirects.yml"):
errors.extend(validate_redirects(rel_path))
if errors:
print("METADATA_ERRORS")
for error in errors:
print(error)
print(f"TOTAL={len(errors)}")
return 1
print("OK metadata paths checked")
return 0
if __name__ == "__main__":
sys.exit(main())