142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
#!/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())
|