Files
ai-proj-helper/scripts/validate-skills.py
T
johnQ 04e20babc1
skill-gate / gate (pull_request) Waiting to run
feat(skill): 本地 git 门禁 + 项目级安装 + 覆盖率与第三方扫描
- hooks/git/{pre-commit,pre-push} + scripts/install-git-hooks.sh(Gitea Actions 无 runner,本地兜底)
- install-skills.sh: --scope project --project-dir(装到项目 .agents/skills)
- scripts/scan-installed.sh: 扫第三方已装技能(33 个,0 高危)
- generate-catalog: has_evals/has_docs + coverage;validate --coverage + 路由一致性检查
- SCOPE 覆盖率路线图(3-4 周补齐 40 个技能的 evals 与四段式文档)
- MAINTENANCE/README/CHANGELOG 同步
2026-11-01 05:04:14 +08:00

218 lines
8.4 KiB
Python
Executable File
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
"""Validate plugins and SKILL.md files against the Agent Skills spec + team rules.
Usage: python3 scripts/validate-skills.py [--strict]
Exit 0 = pass, 1 = errors found.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ALLOWED_FRONTMATTER = {
"name", "description", "license", "compatibility", "metadata",
"allowed-tools", "disable-model-invocation", "version",
}
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
BODY_MAX_BYTES = 20 * 1024
DESC_MIN, DESC_MAX = 30, 1024
STATUSES = {"promoted", "beta", "deprecated"}
INVOCATIONS = {"model-invoked", "user-invoked"}
REQUIRED_PLUGIN_FIELDS = ("install_name", "install_type", "dir_category", "version")
errors: list[str] = []
warnings: list[str] = []
def parse_frontmatter(text: str):
"""Return (frontmatter dict, body text). Supports one level of nesting."""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
raw, body = text[3:end], text[end + 4:]
data: dict = {}
metadata: dict = {}
key = None
for line in raw.splitlines():
if not line.strip():
continue
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
if m:
key = m.group(1)
data[key] = m.group(2).strip().strip('"').strip("'")
continue
m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", line)
if m and key == "metadata":
metadata[m.group(1)] = m.group(2).strip().strip('"').strip("'")
elif key:
data[key] = (data.get(key, "") + " " + line.strip()).strip()
if metadata:
data["metadata"] = metadata
return data, body
def check_plugin(plugin_json: Path):
plugin_dir = plugin_json.parent.parent
rel = plugin_dir.relative_to(ROOT)
try:
meta = json.loads(plugin_json.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
errors.append(f"{rel}: plugin.json 解析失败: {exc}")
return
for field in REQUIRED_PLUGIN_FIELDS:
if not meta.get(field):
errors.append(f"{rel}: plugin.json 缺少 {field}")
status = meta.get("status", "")
if status and status not in STATUSES:
errors.append(f"{rel}: status 非法 {status!r}(应为 {sorted(STATUSES)})")
install_name = meta.get("install_name", "")
if install_name and not NAME_RE.match(install_name):
errors.append(f"{rel}: install_name 不合规 {install_name!r}")
skills_dir = plugin_dir / "skills"
skill_files = sorted(skills_dir.rglob("SKILL.md")) if skills_dir.is_dir() else []
if not skill_files:
errors.append(f"{rel}: 没有找到 skills/**/SKILL.md")
return
for skill_md in skill_files:
check_skill(rel, skill_md, meta)
def check_skill(rel: Path, skill_md: Path, meta: dict):
text = skill_md.read_text(encoding="utf-8", errors="replace")
fm, body = parse_frontmatter(text)
where = f"{rel}/{skill_md.name}"
name = fm.get("name", "")
if not name:
errors.append(f"{where}: frontmatter 缺少 name")
elif not NAME_RE.match(name):
errors.append(f"{where}: name 不合规 {name!r}(小写字母/数字/连字符,≤64)")
desc = fm.get("description", "")
if not desc:
errors.append(f"{where}: frontmatter 缺少 description")
elif not (DESC_MIN <= len(desc) <= DESC_MAX):
warnings.append(f"{where}: description 长度 {len(desc)},建议 {DESC_MIN}-{DESC_MAX}(决定是否被触发)")
for key in fm:
if key == "metadata":
continue
if key not in ALLOWED_FRONTMATTER:
warnings.append(f"{where}: 非标准 frontmatter 字段 {key!r},建议移入 metadata")
body_bytes = len(body.encode("utf-8"))
if body_bytes > BODY_MAX_BYTES:
warnings.append(f"{where}: 正文 {body_bytes} 字节,超过 20KB 预算,建议拆 references/")
metadata = fm.get("metadata") or {}
invocation = metadata.get("invocation", "")
if invocation and invocation not in INVOCATIONS:
errors.append(f"{where}: metadata.invocation 非法 {invocation!r}")
disable_model = str(fm.get("disable-model-invocation", "")).lower() == "true"
if invocation == "user-invoked" and not disable_model:
errors.append(f"{where}: user-invoked 技能必须设置 disable-model-invocation: true")
if invocation == "model-invoked" and disable_model:
errors.append(f"{where}: model-invoked 技能不应设置 disable-model-invocation: true")
status = meta.get("status", "")
if status == "promoted":
docs = ROOT / "docs" / "skills" / f"{meta.get('install_name', name)}.md"
if not docs.is_file():
warnings.append(f"{where}: promoted 技能缺少四段式文档 docs/skills/{docs.name}")
# Relative links must resolve (inline code spans are examples, skip them).
scan_body = re.sub(r"`[^`]*`", "", body)
for target in re.findall(r"\]\((\.[^)\s]+|[A-Za-z0-9_-]+/[^)\s]+)\)", scan_body):
if target.startswith(("http://", "https://", "#")):
continue
if not (skill_md.parent / target).exists():
warnings.append(f"{where}: 引用的相对路径不存在: {target}")
IGNORED_ROUTING_TOKENS = {
"completed", "released", "delivery_stage", "status", "true", "false", "user-invoked",
"model-invoked", "promoted", "beta", "deprecated", "scoop", "scoop.md",
}
EXTERNAL_SKILLS = {
# 本仓库之外、由 DSH 用户目录提供的技能(docs/skill-routing.md 允许引用)
"project-management", "topic-distill", "diary", "dsh-worktable-page",
"code-review", "release-notes", "disk-cleanup", "workflow-one",
}
def check_routing(catalog_names: set[str]):
routing = ROOT / "docs" / "skill-routing.md"
if not routing.is_file():
return
text = routing.read_text(encoding="utf-8")
for token in sorted(set(re.findall(r"`([a-z][a-z0-9-]{2,40})`", text))):
if token in IGNORED_ROUTING_TOKENS or token in EXTERNAL_SKILLS:
continue
if token not in catalog_names:
warnings.append(f"docs/skill-routing.md: 路由到不存在的技能 {token!r}")
def coverage_report() -> int:
missing_evals, missing_docs = [], []
for plugin_json in sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json")):
meta = json.loads(plugin_json.read_text())
if meta.get("status", "promoted") != "promoted":
continue
skill_md = next((plugin_json.parent.parent / "skills").rglob("SKILL.md"), None)
if skill_md is None:
continue
fm, _ = parse_frontmatter(skill_md.read_text(encoding="utf-8", errors="replace"))
if (fm.get("metadata") or {}).get("invocation", "model-invoked") != "model-invoked":
continue
name = meta.get("install_name") or fm.get("name")
if not any((plugin_json.parent.parent / "skills").rglob("evals/evals.json")):
missing_evals.append(name)
if not (ROOT / "docs" / "skills" / f"{name}.md").is_file():
missing_docs.append(name)
print(f"覆盖率(promoted + model-invoked):缺 evals {len(missing_evals)} 个,缺四段式文档 {len(missing_docs)} 个")
if missing_evals:
print("缺 evals:", ", ".join(missing_evals[:20]) + ("..." if len(missing_evals) > 20 else ""))
if missing_docs:
print("缺文档:", ", ".join(missing_docs[:20]) + ("..." if len(missing_docs) > 20 else ""))
return 0
def main() -> int:
strict = "--strict" in sys.argv
if "--coverage" in sys.argv:
return coverage_report()
plugins = sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json"))
if not plugins:
print("未找到任何 plugin.json", file=sys.stderr)
return 1
for plugin_json in plugins:
check_plugin(plugin_json)
catalog = ROOT / "catalog.json"
if catalog.is_file():
names = {x["name"] for x in json.loads(catalog.read_text()).get("skills", [])}
check_routing(names)
for warning in warnings:
print(f"[warn] {warning}")
for error in errors:
print(f"[error] {error}")
print(f"\n检查 {len(plugins)} 个插件:{len(errors)} 个错误,{len(warnings)} 个警告")
if errors:
return 1
if strict and warnings:
print("--strict 模式下,警告视为失败")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())