#!/usr/bin/env python3 """validate_skill_pack.py — 纯确定性 Skill 包静态校验(Phase 0 工具,不调用模型)。 校验内容: 1. 每个含 SKILL.md 的目录视为一个 Skill:frontmatter 必须可解析且含非空 name/description; 2. name 建议匹配 ^[a-z0-9][a-z0-9-]*$ 且与目录名一致(不一致仅告警); 3. 全部 .md 文件必须是合法 UTF-8; 4. 全部 .md 文件中的相对引用(markdown 链接与裸 references/... 路径)必须存在; 5. 输出每个 Skill 的行数/字节数概览。 用法: python3 scripts/validate_skill_pack.py [ ...] 退出码: 0 = 无 ERROR;1 = 存在 ERROR。 """ from __future__ import annotations import re import sys from pathlib import Path import yaml MD_LINK_RE = re.compile(r"\]\(([^)#\s]+?)(?:#[^)\s]*)?\)") BARE_REF_RE = re.compile(r"(? dict | None: if not text.startswith("---"): return None end = text.find("\n---", 3) if end == -1: return None try: data = yaml.safe_load(text[3:end]) except yaml.YAMLError: return None return data if isinstance(data, dict) else None def check_links(md_path: Path, text: str) -> None: base = md_path.parent seen: set[str] = set() for regex in (MD_LINK_RE, BARE_REF_RE): for m in regex.finditer(text): target = m.group(1).strip() if target in seen: continue seen.add(target) if target.startswith(("http://", "https://", "mailto:", "/")): continue if not (base / target).exists(): errors.append(f"[broken-ref] {md_path}: `{target}` 不存在") def check_skill(skill_md: Path) -> None: text = skill_md.read_text(encoding="utf-8") fm = parse_frontmatter(text) if fm is None: errors.append(f"[frontmatter] {skill_md}: frontmatter 缺失或不可解析") return name = fm.get("name") desc = fm.get("description") if not (isinstance(name, str) and name.strip()): errors.append(f"[frontmatter] {skill_md}: 缺少非空 name") else: if not NAME_RE.match(name): warnings.append(f"[name-style] {skill_md}: name `{name}` 不符合小写连字符风格") if name != skill_md.parent.name: warnings.append(f"[name-dir] {skill_md}: name `{name}` 与目录名 `{skill_md.parent.name}` 不一致") if not (isinstance(desc, str) and desc.strip()): errors.append(f"[frontmatter] {skill_md}: 缺少非空 description") def main(argv: list[str]) -> int: if len(argv) < 2: print(__doc__) return 2 skill_count = 0 md_count = 0 for root_arg in argv[1:]: root = Path(root_arg) if not root.is_dir(): errors.append(f"[input] 目录不存在: {root}") continue for md in sorted(root.rglob("*.md")): md_count += 1 try: text = md.read_text(encoding="utf-8") except UnicodeDecodeError: errors.append(f"[encoding] {md}: 非法 UTF-8") continue check_links(md, text) if md.name == "SKILL.md": skill_count += 1 check_skill(md) lines = text.count("\n") + 1 print(f" skill: {md.parent.name:<28} {lines:>4} 行 {len(text.encode('utf-8')):>7} 字节") print(f"\n共扫描 {md_count} 个 .md,其中 {skill_count} 个 SKILL.md") for w in warnings: print(f"WARN {w}") for e in errors: print(f"ERROR {e}") print(f"\n结果: {len(errors)} errors, {len(warnings)} warnings") return 1 if errors else 0 if __name__ == "__main__": raise SystemExit(main(sys.argv))