#!/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()