Files
CraftKit/plugins/skill/skills/guidance-edit/scripts/check_index.py
T

158 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"(?<!!)\[[^\]]*\]\(([^)]+)\)")
def relative_name(path: Path, root: Path) -> 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())