feat(skill): 新增项目规范维护 Skill
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user