skill-gate / gate (pull_request) Waiting to run
- 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 同步
218 lines
8.4 KiB
Python
Executable File
218 lines
8.4 KiB
Python
Executable File
#!/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())
|