#!/usr/bin/env python3 """校验 CraftKit 技术 Profile 清单的结构、标识、版本范围和资料引用。""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any CAPABILITIES = { "project-detection", "knowledge-routing", "backend-design", "backend-implementation", "backend-testing", "language-review", "framework-review", "command-resolution", } KINDS = {"language", "framework", "toolchain"} SCHEMES = {"semver", "pep440", "java-feature", "generic"} ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") PROFILE_PATTERN = re.compile(r"^[a-z0-9-]+/[a-z0-9-]+$") VERSION_PATTERN = re.compile(r"^(?:\*|(?:>=|>|<=|<|==)?\d+(?:\.\d+){0,2}(?:,(?:>=|>|<=|<|==)\d+(?:\.\d+){0,2})*)$") def _require_mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]: """把对象字段收窄为字典,并把类型错误加入统一错误集合。""" if not isinstance(value, dict): errors.append(f"{label} 必须是对象") return {} return value def _validate_reference(skill_root: Path, value: Any, label: str, errors: list[str]) -> None: """保证资料引用为 Skill 内相对文件,阻止绝对路径和目录逃逸。""" if not isinstance(value, str) or not value: errors.append(f"{label} 必须是非空相对路径") return relative = Path(value) if relative.is_absolute() or ".." in relative.parts: errors.append(f"{label} 不能使用绝对路径或目录逃逸: {value}") return target = (skill_root / relative).resolve() try: target.relative_to(skill_root.resolve()) except ValueError: errors.append(f"{label} 超出 Skill 目录: {value}") return if not target.is_file(): errors.append(f"{label} 引用文件不存在: {value}") elif target.stat().st_size == 0: errors.append(f"{label} 引用文件为空: {value}") def validate_manifest(path: Path) -> list[str]: """返回全部可确定的契约错误,便于一次修复多个问题。""" errors: list[str] = [] try: data = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError) as exc: return [f"无法读取 JSON: {exc}"] root = _require_mapping(data, "根节点", errors) if root.get("schemaVersion") != 1: errors.append("schemaVersion 必须为 1") provider = _require_mapping(root.get("provider"), "provider", errors) provider_id = provider.get("id") if not isinstance(provider_id, str) or not ID_PATTERN.fullmatch(provider_id): errors.append("provider.id 必须是小写短横线标识") if provider.get("kind") not in KINDS: errors.append("provider.kind 不受支持") if not isinstance(provider.get("displayName"), str) or not provider.get("displayName"): errors.append("provider.displayName 必须是非空字符串") profiles = root.get("profiles") if not isinstance(profiles, list) or not profiles: errors.append("profiles 必须是非空数组") return errors seen: set[str] = set() skill_root = path.parent.parent for index, raw_profile in enumerate(profiles): label = f"profiles[{index}]" profile = _require_mapping(raw_profile, label, errors) profile_id = profile.get("id") if not isinstance(profile_id, str) or not PROFILE_PATTERN.fullmatch(profile_id): errors.append(f"{label}.id 格式无效") elif profile_id in seen: errors.append(f"{label}.id 重复: {profile_id}") else: seen.add(profile_id) if isinstance(provider_id, str) and isinstance(profile_id, str) and not profile_id.startswith(provider_id + "/"): errors.append(f"{label}.id 必须使用 provider.id 作为前缀") version = _require_mapping(profile.get("version"), f"{label}.version", errors) if version.get("scheme") not in SCHEMES: errors.append(f"{label}.version.scheme 不受支持") version_range = version.get("range") if not isinstance(version_range, str) or not VERSION_PATTERN.fullmatch(version_range): errors.append(f"{label}.version.range 格式无效") if not isinstance(profile.get("priority"), int) or isinstance(profile.get("priority"), bool): errors.append(f"{label}.priority 必须是整数") if not isinstance(profile.get("detect"), dict): errors.append(f"{label}.detect 必须是对象") capabilities = _require_mapping(profile.get("capabilities"), f"{label}.capabilities", errors) if not capabilities: errors.append(f"{label}.capabilities 不能为空") for capability, reference in capabilities.items(): if capability not in CAPABILITIES: errors.append(f"{label}.capabilities 包含未知能力: {capability}") _validate_reference(skill_root, reference, f"{label}.capabilities.{capability}", errors) _validate_reference(skill_root, profile.get("fallback"), f"{label}.fallback", errors) return errors def main() -> int: """解析命令行参数并以退出码表达校验结果。""" parser = argparse.ArgumentParser(description="校验 CraftKit Profile manifest.json") parser.add_argument("manifests", nargs="+", type=Path, help="一个或多个 manifest.json 路径") args = parser.parse_args() failed = False for manifest in args.manifests: errors = validate_manifest(manifest.resolve()) if errors: failed = True print(f"FAIL {manifest}") for error in errors: print(f" - {error}") else: print(f"PASS {manifest}") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())