Ctrl K
ComunidadGuías…

skills-library/validate.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""skills-library 结构验证脚本.

校验标准与运行时加载器 scout/context/skills.py:SkillManager 保持一致
(直接复用其 frontmatter 解析逻辑,不另造一套),并在其之上叠加
skills-library/README.md 记载的库内格式要求:

  1. 每个技能目录必须有 SKILL.md,且带完整 YAML frontmatter(--- 包裹)
  2. frontmatter 必填字段非空:name / description / trigger / version / author
  3. name 必须与目录名一致(运行时缺 name 会静默回退目录名,库内不允许歧义)
  4. version 必须是 X.Y.Z 语义化格式
  5. 运行时加载器能成功解析该文件(round-trip 一致)
  6. 正文中 markdown 图片/链接引用的相对资源文件必须真实存在

用法(仓库内任意位置均可):
    python skills-library/validate.py

退出码: 0 = 全部 PASS; 1 = 存在 FAIL; 2 = 环境错误(无法导入运行时加载器)
"""

from __future__ import annotations

import re
import sys
from pathlib import Path
from typing import Any
from urllib.parse import unquote

# ── 路径 ──
LIB_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = LIB_DIR.parent

# ── 库内格式要求(对应 README.md "格式" 一节)──
REQUIRED_FIELDS = ("name", "description", "trigger", "version", "author")
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")

# markdown 图片 ![alt](target) 与普通链接 [text](target)
_MD_IMAGE_RE = re.compile(r"!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)")
_MD_LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)")
_EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "data:", "#")
_WIN_ABS_RE = re.compile(r"^[A-Za-z]:[\\/]")


def _load_runtime_loader() -> type:
    """导入运行时技能加载器(复用其解析逻辑,保证标准一致)."""
    root = str(PROJECT_ROOT)
    if root not in sys.path:
        sys.path.insert(0, root)
    from scout.context.skills import SkillManager  # noqa: PLC0415

    return SkillManager


def _split_frontmatter(content: str) -> tuple[str, str] | None:
    """切分 frontmatter / 正文 — 与 SkillManager._parse_skill_md 相同的算法."""
    if not content.startswith("---"):
        return None
    end = content.find("\n---", 3)
    if end <= 0:
        return None
    return content[3:end].strip(), content[end + 4:].strip()


def _is_external_or_absolute(target: str) -> bool:
    """链接目标是否为外链/锚点/绝对路径(无需本地存在)."""
    return (
        target.startswith(_EXTERNAL_PREFIXES)
        or target.startswith(("/", "\\"))
        or bool(_WIN_ABS_RE.match(target))
    )


def _missing_resources(skill_dir: Path, body: str) -> list[str]:
    """收集正文中引用了但磁盘上不存在的相对资源."""
    missing: list[str] = []
    for target in [m.group(1) for m in _MD_IMAGE_RE.finditer(body)] + [
        m.group(1) for m in _MD_LINK_RE.finditer(body)
    ]:
        if _is_external_or_absolute(target):
            continue
        # 去掉 #fragment、解码 %20 等转义后按技能目录解析
        rel = unquote(target.split("#", 1)[0])
        if not rel:
            continue
        resolved = (skill_dir / rel).resolve()
        try:
            resolved.relative_to(skill_dir.resolve())
        except ValueError:
            missing.append(f"资源引用越出技能目录: {target}")
            continue
        if not resolved.exists():
            missing.append(f"引用的相对资源不存在: {target}")
    return missing


def validate_skill_dir(
    skill_dir: Path, skill_manager_cls: type | None = None
) -> list[str]:
    """校验单个技能目录,返回问题清单(空列表 = 通过)."""
    problems: list[str] = []
    if skill_manager_cls is None:
        skill_manager_cls = _load_runtime_loader()

    skill_md = skill_dir / "SKILL.md"
    if not skill_md.exists():
        return [f"缺少 {skill_md.name}"]

    content = skill_md.read_text(encoding="utf-8")
    parts = _split_frontmatter(content)
    if parts is None:
        return ["缺少 YAML frontmatter(须以 --- 开始并闭合)"]
    frontmatter, body = parts

    meta: dict[str, Any] = skill_manager_cls._parse_frontmatter(frontmatter)
    if not isinstance(meta, dict) or not meta:
        return ["frontmatter 解析结果为空(不是合法的 key: value 结构)"]

    # 1) 必填字段非空
    for field in REQUIRED_FIELDS:
        value = meta.get(field)
        if value is None or (isinstance(value, str) and not value.strip()):
            problems.append(f"frontmatter 缺少必填字段或为空: {field}")
        elif not isinstance(value, str):
            problems.append(f"frontmatter 字段 {field} 应为字符串,实际为 {type(value).__name__}")

    # 2) name 与目录名一致
    name = meta.get("name")
    if isinstance(name, str) and name.strip() and name.strip() != skill_dir.name:
        problems.append(f"name ({name.strip()!r}) 与目录名 ({skill_dir.name!r}) 不一致")

    # 3) version 格式
    version = meta.get("version")
    if isinstance(version, str) and version.strip() and not VERSION_RE.match(version.strip()):
        problems.append(f"version ({version.strip()!r}) 不符合 X.Y.Z 格式")

    # 4) 运行时加载器 round-trip(与 SkillManager._parse_skill_md 一致)
    loader = skill_manager_cls.__new__(skill_manager_cls)
    skill = loader._parse_skill_md(skill_md, scope="repo")
    if skill is None:
        problems.append("运行时加载器解析失败(_parse_skill_md 返回 None)")
    else:
        if skill.name != skill_dir.name:
            problems.append(f"运行时加载得到的 name ({skill.name!r}) 与目录名不一致")
        if not skill.instructions.strip():
            problems.append("正文(指令)为空")
        if not skill.trigger_keywords:
            problems.append("trigger 解析后无有效关键词(无法被触发命中)")

    # 5) 相对资源引用存在性
    problems.extend(_missing_resources(skill_dir, body))

    return problems


def iter_skill_dirs(lib_dir: Path = LIB_DIR) -> list[Path]:
    """列出库内全部技能目录(跳过隐藏/下划线开头的非技能目录)."""
    return sorted(
        d for d in lib_dir.iterdir()
        if d.is_dir() and not d.name.startswith((".", "_"))
    )


def validate_library(lib_dir: Path = LIB_DIR) -> list[tuple[str, list[str]]]:
    """校验整个技能库,返回 [(技能目录名, 问题清单)],按目录名排序."""
    skill_manager_cls = _load_runtime_loader()
    return [
        (d.name, validate_skill_dir(d, skill_manager_cls))
        for d in iter_skill_dirs(lib_dir)
    ]


def main(argv: list[str] | None = None) -> int:
    if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
        try:
            sys.stdout.reconfigure(encoding="utf-8", errors="replace")
        except Exception:
            pass

    try:
        results = validate_library()
    except Exception as exc:  # 环境问题:无法导入 scout 运行时加载器等
        print(f"ERROR: 无法执行校验(环境问题): {exc}")
        print("请在仓库内运行,例如: python skills-library/validate.py")
        return 2

    print(f"skills-library 校验({len(results)} 个技能包)")
    print("=" * 60)
    failed = 0
    for name, problems in results:
        if problems:
            failed += 1
            print(f"FAIL  {name}")
            for p in problems:
                print(f"      - {p}")
        else:
            print(f"PASS  {name}")

    print("=" * 60)
    passed = len(results) - failed
    print(f"结果: {passed} PASS / {failed} FAIL,共 {len(results)} 个")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())