#!/usr/bin/env python3 """检查项目规范 Markdown 文件能否从指定索引入口访问。""" from __future__ import annotations import argparse import json from pathlib import Path import re import sys from urllib.parse import unquote LINK_RE = re.compile(r"(? str: """返回使用正斜杠的规范根相对路径。""" return path.relative_to(root).as_posix() def markdown_targets(path: Path, root: Path) -> tuple[list[Path], list[str]]: """提取指向规范根内 Markdown 文件的相对链接。""" targets: list[Path] = [] escaped: list[str] = [] content = path.read_text(encoding="utf-8") for raw in LINK_RE.findall(content): value = raw.strip().split(maxsplit=1)[0].strip("<>") if not value or value.startswith(("#", "http://", "https://", "mailto:")): continue value = unquote(value.split("#", 1)[0]) if not value.lower().endswith(".md"): continue target = (path.parent / value).resolve() try: target.relative_to(root) except ValueError: escaped.append(raw) continue targets.append(target) return targets, escaped def inspect(root: Path, entry_name: str) -> dict[str, object]: """遍历索引图并返回可序列化检查结果。""" root = root.resolve() entry = (root / entry_name).resolve() if not root.is_dir(): raise ValueError(f"规范目录不存在:{root}") try: entry.relative_to(root) except ValueError as exc: raise ValueError("入口必须位于规范目录内") from exc if not entry.is_file(): raise ValueError(f"索引入口不存在:{entry_name}") all_files = {path.resolve() for path in root.rglob("*.md") if path.is_file()} graph: dict[Path, list[Path]] = {} broken: list[dict[str, str]] = [] escaped: list[dict[str, str]] = [] reachable: set[Path] = set() pending = [entry] while pending: current = pending.pop() if current in reachable: continue reachable.add(current) targets, outside = markdown_targets(current, root) graph[current] = [] for raw in outside: escaped.append({"source": relative_name(current, root), "target": raw}) for target in targets: if not target.is_file(): broken.append( { "source": relative_name(current, root), "target": relative_name(target, root), } ) continue graph[current].append(target) pending.append(target) cycles: set[tuple[str, ...]] = set() active: list[Path] = [] visited: set[Path] = set() def visit(node: Path) -> None: """深度优先查找索引图中的回边。""" if node in active: start = active.index(node) cycle = active[start:] + [node] cycles.add(tuple(relative_name(item, root) for item in cycle)) return if node in visited: return active.append(node) for target in graph.get(node, []): visit(target) active.pop() visited.add(node) visit(entry) return { "root": str(root), "entry": relative_name(entry, root), "reachable": sorted(relative_name(path, root) for path in reachable), "unindexed": sorted(relative_name(path, root) for path in all_files - reachable), "broken": sorted(broken, key=lambda item: (item["source"], item["target"])), "outsideRoot": sorted(escaped, key=lambda item: (item["source"], item["target"])), "cycles": [list(cycle) for cycle in sorted(cycles)], } def has_findings(report: dict[str, object]) -> bool: """判断报告是否包含需要处理的问题。""" return any(report[key] for key in ("unindexed", "broken", "outsideRoot", "cycles")) def print_text(report: dict[str, object]) -> None: """输出便于人工阅读的简洁报告。""" print(f"入口:{report['entry']}") print(f"可达文件:{len(report['reachable'])}") for label, key in ( ("未索引文件", "unindexed"), ("失效链接", "broken"), ("越界链接", "outsideRoot"), ("循环引用", "cycles"), ): values = report[key] print(f"{label}:{len(values)}") for value in values: print(f" - {value}") def main() -> int: """解析命令行参数并返回稳定退出码。""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("root", type=Path, help="规范根目录") parser.add_argument("--entry", default="index.md", help="规范根内的索引入口") parser.add_argument("--json", action="store_true", help="输出 JSON 报告") args = parser.parse_args() try: report = inspect(args.root, args.entry) except (OSError, UnicodeError, ValueError) as exc: print(f"错误:{exc}", file=sys.stderr) return 2 if args.json: print(json.dumps(report, ensure_ascii=False, indent=2)) else: print_text(report) return 1 if has_findings(report) else 0 if __name__ == "__main__": raise SystemExit(main())