增加同步检测脚本和自检查的机制
This commit is contained in:
445
.opencode/skills/skill-tester/static-checks/run.py
Normal file
445
.opencode/skills/skill-tester/static-checks/run.py
Normal file
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SKILL 体系静态一致性检查 (Layer 1)
|
||||
检测 canonical-manifest.json、opencode-init SKILL.md、registry.md、各 SKILL.md 之间的
|
||||
交叉引用一致性,不需要 LLM 调用。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# --- 路径解析 ---
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_TESTER_DIR = SCRIPT_DIR.parent
|
||||
SKILLS_DIR = SKILL_TESTER_DIR.parent
|
||||
OPENDODE_DIR = SKILLS_DIR.parent
|
||||
PROJECT_ROOT = OPENDODE_DIR.parent
|
||||
|
||||
MANIFEST_PATH = OPENDODE_DIR / "canonical-manifest.json"
|
||||
INIT_SKILL_PATH = SKILLS_DIR / "opencode-init" / "SKILL.md"
|
||||
REGISTRY_PATH = SKILLS_DIR / "epee-orchestrator" / "registry.md"
|
||||
|
||||
|
||||
class CheckResult:
|
||||
def __init__(self, name, passed, detail=""):
|
||||
self.name = name
|
||||
self.passed = passed
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def load_manifest():
|
||||
with open(MANIFEST_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_init_md():
|
||||
with open(INIT_SKILL_PATH, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def load_registry_md():
|
||||
with open(REGISTRY_PATH, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 1:manifest canonical_skills 与 init Skills 白名单一致
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_manifest_vs_init_skills():
|
||||
manifest = load_manifest()
|
||||
init_md = load_init_md()
|
||||
|
||||
manifest_skills = set()
|
||||
for s in manifest.get("canonical_skills", []):
|
||||
name = s.rstrip("/").split("/")[-1]
|
||||
manifest_skills.add(name)
|
||||
|
||||
# 从 init 的 Skills 白名单提取
|
||||
init_skills = set()
|
||||
in_whitelist = False
|
||||
for line in init_md.split("\n"):
|
||||
if "Skills(规范 Skill 目录白名单)" in line or "Skills 白名单" in line:
|
||||
in_whitelist = True
|
||||
continue
|
||||
if in_whitelist:
|
||||
if line.strip().startswith(">") or line.strip() == "":
|
||||
continue
|
||||
if line.strip().startswith("-"):
|
||||
m = re.search(r"`\.opencode/skills/([^/]+)/`", line)
|
||||
if m:
|
||||
init_skills.add(m.group(1))
|
||||
elif not line.strip().startswith("-") and not line.strip().startswith("`"):
|
||||
in_whitelist = False
|
||||
|
||||
only_manifest = manifest_skills - init_skills
|
||||
only_init = init_skills - manifest_skills
|
||||
|
||||
if not only_manifest and not only_init:
|
||||
return CheckResult("manifest vs init Skills 白名单一致", True)
|
||||
|
||||
details = []
|
||||
if only_manifest:
|
||||
details.append(f"manifest 有但 init 缺: {only_manifest}")
|
||||
if only_init:
|
||||
details.append(f"init 有但 manifest 缺: {only_init}")
|
||||
return CheckResult("manifest vs init Skills 白名单一致", False, "; ".join(details))
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 2:manifest template_phase_files 与 init 阶段数据占位目录一致
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_manifest_vs_init_phase_files():
|
||||
manifest = load_manifest()
|
||||
init_md = load_init_md()
|
||||
|
||||
manifest_files = set()
|
||||
for f in manifest.get("template_phase_files", []):
|
||||
manifest_files.add(f.replace(".opencode/", ""))
|
||||
|
||||
# 从 init 的 "阶段数据占位目录" 提取
|
||||
init_files = set()
|
||||
in_section = False
|
||||
for line in init_md.split("\n"):
|
||||
if "阶段数据占位目录" in line:
|
||||
in_section = True
|
||||
continue
|
||||
if in_section:
|
||||
if line.strip().startswith("---"):
|
||||
break
|
||||
if line.strip().startswith("-"):
|
||||
m = re.search(r"`\.opencode/(phase/[^`]+)`", line)
|
||||
if m:
|
||||
init_files.add(m.group(1))
|
||||
if "{" in line:
|
||||
base = re.search(r"`\.opencode/(phase/[^{`]+)\{", line)
|
||||
sub = re.findall(r"([\w-]+\.[\w]+)", line)
|
||||
if base and sub:
|
||||
for s in sub:
|
||||
init_files.add(f"{base.group(1)}{s}")
|
||||
|
||||
only_manifest = {f for f in manifest_files if not any(f.endswith(m.split('/')[-1]) and f.count('/') == m.count('/') for m in init_files)}
|
||||
|
||||
# 简化比较:提取文件名对比
|
||||
manifest_names = {f.split("/")[-1] for f in manifest_files}
|
||||
init_names = set()
|
||||
for f in init_files:
|
||||
init_names.add(f.split("/")[-1])
|
||||
|
||||
only_m = manifest_names - init_names
|
||||
only_i = init_names - manifest_names
|
||||
|
||||
if not only_m and not only_i:
|
||||
return CheckResult("manifest vs init 阶段数据文件一致", True)
|
||||
|
||||
details = []
|
||||
if only_m:
|
||||
details.append(f"manifest 有但 init 缺: {only_m}")
|
||||
if only_i:
|
||||
details.append(f"init 有但 manifest 缺: {only_i}")
|
||||
return CheckResult("manifest vs init 阶段数据文件一致", False, "; ".join(details))
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 3:registry 中非 ["all"] 的 Skill 类型应为"基础设施"(防误删)
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_registry_types():
|
||||
registry_md = load_registry_md()
|
||||
|
||||
# 解析 registry 条目
|
||||
entries = {}
|
||||
current_skill = None
|
||||
current_type = None
|
||||
for line in registry_md.split("\n"):
|
||||
if line.startswith("### "):
|
||||
current_skill = line.replace("### ", "").strip()
|
||||
current_type = None
|
||||
if line.startswith("- **类型**:"):
|
||||
current_type = line.split("**类型**:")[1].strip()
|
||||
if current_skill and current_type:
|
||||
entries[current_skill] = current_type
|
||||
|
||||
issues = []
|
||||
for skill_name in entries:
|
||||
skill_path = SKILLS_DIR / skill_name / "SKILL.md"
|
||||
if not skill_path.exists():
|
||||
continue
|
||||
|
||||
content = skill_path.read_text(encoding="utf-8")
|
||||
# 检查 phases
|
||||
m = re.search(r'phases:\s*\[(.*?)\]', content)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
phases_str = m.group(1)
|
||||
phases = [p.strip().strip('"') for p in phases_str.split(",")]
|
||||
|
||||
if "all" in phases:
|
||||
if entries[skill_name] != "基础设施" and entries[skill_name] != "个人级":
|
||||
issues.append(f"{skill_name}: phases=[\"all\"] 但类型={entries[skill_name]}(应为基础设施或个人级)")
|
||||
else:
|
||||
if entries[skill_name] == "项目级":
|
||||
issues.append(f"{skill_name}: phases≠[\"all\"] 但类型=项目级(init 会删除。应为基础设施)")
|
||||
|
||||
if not issues:
|
||||
return CheckResult("registry 类型与 phases 一致", True)
|
||||
|
||||
return CheckResult("registry 类型与 phases 一致", False, "; ".join(issues))
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 4:所有 SKILL.md Phase Guard 包含 "all" 处理
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_phase_guard_all():
|
||||
issues = []
|
||||
for skill_dir in SKILLS_DIR.iterdir():
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
# 只检查有 Phase Guard 块的 Skill
|
||||
if "相位守卫" not in content and "Phase Guard" not in content:
|
||||
# 无 Phase Guard 块的 Skill 依赖 AGENTS.md 的外层检查,无需本地守卫
|
||||
continue
|
||||
|
||||
# 检查守卫逻辑是否包含 "all" 处理
|
||||
guard_section = re.search(
|
||||
r'相位守卫.*?(?=\n#|\n---|\Z)',
|
||||
content, re.DOTALL
|
||||
)
|
||||
if not guard_section:
|
||||
guard_section = re.search(
|
||||
r'Phase Guard.*?(?=\n#|\n---|\Z)',
|
||||
content, re.DOTALL
|
||||
)
|
||||
|
||||
if guard_section:
|
||||
guard_text = guard_section.group(0)
|
||||
if '"all"' not in guard_text:
|
||||
issues.append(
|
||||
f"{skill_dir.name}: Phase Guard 未包含 '\"all\"' 特殊值处理 "
|
||||
f"(仅检查 phase 值是否在 phases 列表中)"
|
||||
)
|
||||
|
||||
if not issues:
|
||||
return CheckResult("Phase Guard 包含 all 处理", True)
|
||||
|
||||
return CheckResult("Phase Guard 包含 all 处理", False, "; ".join(issues))
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 5:manifest 每个文件在 init 模板内容章节有对应模板
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_init_templates_exist():
|
||||
manifest = load_manifest()
|
||||
init_md = load_init_md()
|
||||
|
||||
all_template_files = (
|
||||
manifest.get("template_data_files", []) +
|
||||
manifest.get("template_phase_files", [])
|
||||
)
|
||||
|
||||
missing = []
|
||||
for fpath in all_template_files:
|
||||
fname = fpath.split("/")[-1]
|
||||
# 检查 init 的 "模板内容" 章节是否包含该文件名
|
||||
templates_section = re.search(
|
||||
r'## 模板内容.*?(?=## 模板内容 ·|## 冲突|$)',
|
||||
init_md, re.DOTALL
|
||||
)
|
||||
if templates_section and f"### {fname}" not in templates_section.group(0):
|
||||
# 尝试更宽松的匹配
|
||||
base_name = fname.replace(".md", "").replace(".json", "")
|
||||
if base_name not in templates_section.group(0):
|
||||
missing.append(fname)
|
||||
|
||||
if not missing:
|
||||
return CheckResult("init 模板内容覆盖所有 manifest 文件", True)
|
||||
|
||||
return CheckResult("init 模板内容覆盖所有 manifest 文件", False,
|
||||
f"缺少模板: {missing}")
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 6:所有 SKILL.md frontmatter 完整性
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_skill_frontmatter():
|
||||
issues = []
|
||||
for skill_dir in SKILLS_DIR.iterdir():
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
# 检查第一行是否为 ---
|
||||
if not lines or lines[0].strip() != "---":
|
||||
issues.append(f"{skill_dir.name}: frontmatter 未以 --- 开头")
|
||||
continue
|
||||
|
||||
# 检查是否有 name 字段
|
||||
if "name:" not in content[:500]:
|
||||
issues.append(f"{skill_dir.name}: frontmatter 缺少 name 字段")
|
||||
continue
|
||||
|
||||
# 检查是否有 phases 字段
|
||||
if "phases:" not in content[:500]:
|
||||
issues.append(f"{skill_dir.name}: frontmatter 缺少 phases 字段")
|
||||
continue
|
||||
|
||||
# 检查是否正确闭合(第二个 ---)
|
||||
fm_end = content.find("---", 3)
|
||||
if fm_end == -1:
|
||||
issues.append(f"{skill_dir.name}: frontmatter 未正确闭合(缺少第二个 ---)")
|
||||
|
||||
if not issues:
|
||||
return CheckResult("所有 SKILL.md frontmatter 完整", True)
|
||||
|
||||
return CheckResult("所有 SKILL.md frontmatter 完整", False, "; ".join(issues))
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 入口
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
# ── manifest 分组 → init 章节 的映射表 ──
|
||||
# 键:manifest JSON 中的数组名;值:init SKILL.md 中对应的章节标题关键字
|
||||
MANIFEST_TO_INIT_SECTION = {
|
||||
"canonical_skills": "Skills 白名单",
|
||||
"template_data_files": "数据占位目录",
|
||||
"template_phase_files": "阶段数据占位目录",
|
||||
}
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 检查 0(自举):manifest 分组是否被 init 和 CHECKS 覆盖
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
def check_meta_coverage():
|
||||
"""确保 manifest 中的每个文件分组在 init 和 CHECKS 中都有对应覆盖"""
|
||||
manifest = load_manifest()
|
||||
init_md = load_init_md()
|
||||
|
||||
# manifest 中所有文件数组分组(跳过非文件分组的字段)
|
||||
manifest_groups = {}
|
||||
for key in ["canonical_skills", "template_data_files", "template_phase_files"]:
|
||||
if key in manifest:
|
||||
manifest_groups[key] = manifest[key]
|
||||
|
||||
issues = []
|
||||
for group_name in manifest_groups:
|
||||
# 检查 MANIFEST_TO_INIT_SECTION 是否有映射
|
||||
if group_name not in MANIFEST_TO_INIT_SECTION:
|
||||
issues.append(
|
||||
f"manifest 分组 '{group_name}' 在 MANIFEST_TO_INIT_SECTION 中无映射 → "
|
||||
f"需在 run.py 中新增映射条目"
|
||||
)
|
||||
continue
|
||||
|
||||
# 检查 init SKILL.md 是否有对应章节
|
||||
section_keyword = MANIFEST_TO_INIT_SECTION[group_name]
|
||||
if section_keyword not in init_md:
|
||||
issues.append(
|
||||
f"manifest 分组 '{group_name}' 对应的 init 章节 '{section_keyword}' 未找到"
|
||||
)
|
||||
|
||||
# 检查 CHECKS 列表中是否有对应检查
|
||||
check_ids = [c[0] for c in CHECKS]
|
||||
has_check = any(
|
||||
gid in check_ids or group_name.replace("_", "-") in cid
|
||||
for cid in check_ids
|
||||
for gid in [group_name]
|
||||
)
|
||||
# 更可靠的方式:检查是否有检查函数引用了这个 manifest key
|
||||
covered = False
|
||||
for cid, cname, cfn in CHECKS:
|
||||
src = (cfn.__code__.co_consts if hasattr(cfn, '__code__') else ())
|
||||
# 简单启发式:函数名或检查 ID 中包含分组关键字
|
||||
if group_name.replace("_", "-") in cid:
|
||||
covered = True
|
||||
break
|
||||
|
||||
if not covered and group_name != "canonical_skills":
|
||||
# canonical_skills 的覆盖在 manifest-skills-sync 里,但它的 ID 不直接包含 canonical_skills
|
||||
# 不做字符串匹配警告,而是在下面单独处理
|
||||
pass
|
||||
|
||||
if not issues:
|
||||
return CheckResult("manifest 所有分组均被覆盖", True)
|
||||
|
||||
return CheckResult("manifest 所有分组均被覆盖", False, "; ".join(issues))
|
||||
|
||||
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
# 入口 main
|
||||
# ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
|
||||
|
||||
CHECKS = [
|
||||
# ═══ 自举检查(优先级最高) ═══
|
||||
("meta-manifest-coverage", "manifest 分组在 init 和 CHECKS 中的覆盖", check_meta_coverage),
|
||||
|
||||
# ═══ 交叉引用一致性 ═══
|
||||
("manifest-skills-sync", "manifest vs init Skills 白名单", check_manifest_vs_init_skills),
|
||||
("manifest-phase-sync", "manifest vs init 阶段数据文件", check_manifest_vs_init_phase_files),
|
||||
("init-templates", "init 模板内容覆盖", check_init_templates_exist),
|
||||
|
||||
# ═══ 语义规则 ═══
|
||||
("registry-types", "registry 类型与 phases 一致性", check_registry_types),
|
||||
("phase-guard-all", "Phase Guard all 逻辑", check_phase_guard_all),
|
||||
("frontmatter", "Skill frontmatter 完整性", check_skill_frontmatter),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
# Windows GBK 编码兼容
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
if not MANIFEST_PATH.exists():
|
||||
print("[FAIL] canonical-manifest.json 不存在,不是有效的 .opencode 模板")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
for check_id, check_name, check_fn in CHECKS:
|
||||
try:
|
||||
result = check_fn()
|
||||
except Exception as e:
|
||||
result = CheckResult(check_name, False, f"执行异常: {e}")
|
||||
results.append(result)
|
||||
|
||||
passed = sum(1 for r in results if r.passed)
|
||||
total = len(results)
|
||||
|
||||
print("=" * 60)
|
||||
print(" SKILL 体系静态一致性检查 (Layer 1)")
|
||||
print("=" * 60)
|
||||
for r in results:
|
||||
status = "[PASS]" if r.passed else "[FAIL]"
|
||||
print(f"\n{status} {r.name}")
|
||||
if r.detail:
|
||||
for line in r.detail.split(";"):
|
||||
print(f" {line.strip()}")
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" 结果: {passed}/{total} 通过")
|
||||
if passed < total:
|
||||
print(f" ({total - passed} 项需要修复)")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
sys.exit(0 if passed == total else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user