#!/usr/bin/env python3 """LeMetaSkill 结构体检:检查一个 skill 目录是否满足基本契约。 只依赖标准库。检查项(FAIL 阻断 / WARN 提醒): - SKILL.md 存在、frontmatter 可解析 [FAIL] - name 存在、kebab-case、1-64 字符、与目录名一致 [FAIL] - description 存在且够长(>=30 字符) [WARN] - description 含负触发信号("不要用于"类表述) [WARN] - 正文非空、<=500 行 [WARN] - 正文中的本地 .md 链接可达(无悬空引用) [FAIL] - evals/*.json 可解析 [FAIL] - 无密钥/私有绝对路径 [WARN] - 顶层目录都是已知用途 [WARN] 用法: python3 validate_skill.py """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path KNOWN_DIRS = {"references", "scripts", "evals", "assets", "reports", "examples", "tests"} MAX_BODY_LINES = 500 SECRET_PATTERNS = [ r"AKIA[0-9A-Z]{16}", r"sk-[A-Za-z0-9]{20,}", r"(?:api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}", r"ghp_[A-Za-z0-9]{36}", ] PRIVATE_PATH = "/Users" + "/[A-Za-z0-9_.\\-]+/" # 拼接写法:避免扫描器匹配到自身源码 NEGATIVE_HINT = re.compile(r"不要|不该|不用于|排除|exclude|not for|don'?t use|do not use", re.I) MD_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") def parse_frontmatter(text: str) -> tuple[dict[str, str], int]: """解析简易 YAML frontmatter(key: value 与 key: | 块),返回 (字段, 正文起始行号)。""" lines = text.splitlines() if not lines or lines[0].strip() != "---": return {}, 0 fields: dict[str, str] = {} key = "" for i in range(1, min(len(lines), 60)): line = lines[i] if line.strip() == "---": return fields, i + 1 m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line) if m: key = m.group(1) fields[key] = m.group(2).strip().strip("'\"") elif line.startswith((" ", "\t")) and key: fields[key] = (fields[key] + " " + line.strip().strip("'\"")).strip() return {}, 0 # 没有闭合的 --- def main() -> int: ap = argparse.ArgumentParser(description="skill 结构体检") ap.add_argument("skill_dir", type=Path) args = ap.parse_args() root: Path = args.skill_dir.resolve() if not root.is_dir(): print(f"FAIL 目录不存在: {root}") return 1 n_pass = n_warn = n_fail = 0 def ok(msg: str) -> None: nonlocal n_pass n_pass += 1 print(f"PASS {msg}") def warn(msg: str) -> None: nonlocal n_warn n_warn += 1 print(f"WARN {msg}") def fail(msg: str) -> None: nonlocal n_fail n_fail += 1 print(f"FAIL {msg}") skill_md = root / "SKILL.md" if not skill_md.is_file(): fail("SKILL.md 不存在") print(f"\n--- Summary --- PASS:{n_pass} WARN:{n_warn} FAIL:{n_fail}") return 1 ok("SKILL.md 存在") text = skill_md.read_text(encoding="utf-8") fields, body_start = parse_frontmatter(text) if not fields: fail("frontmatter 缺失或未闭合(需要 --- 包裹的头)") else: ok("frontmatter 可解析") name = fields.get("name", "") if not name: fail("缺少 name 字段") else: if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", name): fail(f"name 不是 kebab-case: {name!r}") elif not 1 <= len(name) <= 64: fail(f"name 长度超出 1-64: {len(name)}") else: ok(f"name 合法: {name}") if root.name != name: fail(f"name 与目录名不一致: {name!r} vs {root.name!r}") else: ok("name 与目录名一致") desc = fields.get("description", "") if not desc: fail("缺少 description 字段(触发信号,必填)") else: if len(desc) < 30: warn(f"description 太短({len(desc)} 字符),盖不住触发场景") else: ok(f"description 长度充分({len(desc)} 字符)") if NEGATIVE_HINT.search(desc): ok("description 含负触发信号") else: warn("description 缺负触发(\"不要用于…\"),边界请求会犹豫") body = "\n".join(text.splitlines()[body_start:]).strip() if not body: fail("正文为空") else: n_lines = len(body.splitlines()) if n_lines > MAX_BODY_LINES: warn(f"正文 {n_lines} 行,超过 {MAX_BODY_LINES} 行预算——考虑拆进 references/") else: ok(f"正文 {n_lines} 行,在预算内") # 悬空引用 dangling = [] for target in MD_LINK.findall(body): if target.startswith(("http://", "https://", "mailto:", "#")): continue if not (root / target).exists(): dangling.append(target) if dangling: fail(f"正文引用的本地文件不存在: {', '.join(sorted(set(dangling)))}") elif MD_LINK.search(body): ok("正文本地链接全部可达") # evals JSON evals_dir = root / "evals" if evals_dir.is_dir(): for j in sorted(evals_dir.glob("*.json")): try: json.loads(j.read_text(encoding="utf-8")) ok(f"{j.relative_to(root)} 可解析") except json.JSONDecodeError as e: fail(f"{j.relative_to(root)} JSON 解析失败: {e}") # 密钥与私有路径(只读文本类文件) for p in sorted(root.rglob("*")): if not p.is_file() or p.suffix not in {".md", ".json", ".py", ".sh", ".txt", ".yaml", ".yml"}: continue try: content = p.read_text(encoding="utf-8") except (UnicodeDecodeError, PermissionError): continue rel = p.relative_to(root) for pat in SECRET_PATTERNS: if re.search(pat, content): warn(f"{rel} 疑似包含密钥/令牌(模式 {pat})") if rel.name != "SKILL.md" and re.search(PRIVATE_PATH, content): warn(f"{rel} 含私有绝对路径(/Users/…)") # 顶层结构 unknown = [d.name for d in root.iterdir() if d.is_dir() and d.name not in KNOWN_DIRS] if unknown: warn(f"未知顶层目录(确认其用途,或移除): {', '.join(unknown)}") present = [d.name for d in root.iterdir() if d.is_dir() and d.name in KNOWN_DIRS] if present: ok(f"标准目录: {', '.join(sorted(present))}") print(f"\n--- Summary --- PASS:{n_pass} WARN:{n_warn} FAIL:{n_fail}") if n_fail: print("结论:未过体检,先修 FAIL 再交付。") return 1 if n_warn: print("结论:可交付,WARN 项酌情处理。") else: print("结论:全绿。") return 0 if __name__ == "__main__": sys.exit(main())