| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- from __future__ import annotations
- import argparse
- import json
- import re
- import sys
- from pathlib import Path
- from typing import Any
- import sync_plugin_version
- SCHEMA_VERSION = "webnovel-plugin-package-validator/v1"
- PLUGIN_NAME = "webnovel-writer"
- KEBAB_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
- SEMVER_RE = sync_plugin_version.VERSION_PATTERN
- LOCAL_ABSOLUTE_RE = re.compile(r"(?i)(?:[a-z]:\\users\\|/users/[^/\s]+/|/home/[^/\s]+/)")
- def _issue(
- code: str,
- *,
- message: str,
- severity: str = "error",
- path: str = "",
- repair: str = "",
- ) -> dict[str, str]:
- return {
- "code": code,
- "severity": severity,
- "message": message,
- "path": path,
- "repair": repair,
- }
- def _load_json(path: Path) -> tuple[dict[str, Any], str]:
- try:
- payload = json.loads(path.read_text(encoding="utf-8"))
- except FileNotFoundError:
- return {}, "missing"
- except json.JSONDecodeError as exc:
- return {}, f"invalid_json:{exc}"
- except OSError as exc:
- return {}, f"read_error:{exc}"
- if not isinstance(payload, dict):
- return {}, "not_object"
- return payload, ""
- def _frontmatter(path: Path) -> dict[str, str]:
- try:
- text = path.read_text(encoding="utf-8")
- except OSError:
- return {}
- if not text.startswith("---"):
- return {}
- end = text.find("\n---", 3)
- if end < 0:
- return {}
- result: dict[str, str] = {}
- for line in text[3:end].splitlines():
- if ":" not in line:
- continue
- key, _, value = line.partition(":")
- result[key.strip()] = value.strip()
- return result
- def _marketplace_plugin(payload: dict[str, Any]) -> dict[str, Any] | None:
- plugins = payload.get("plugins")
- if not isinstance(plugins, list):
- return None
- for item in plugins:
- if isinstance(item, dict) and item.get("name") == PLUGIN_NAME:
- return item
- return None
- def _is_plugin_root(root: Path) -> bool:
- return (root / ".claude-plugin" / "plugin.json").is_file()
- def _plugin_root(root: Path) -> Path:
- return root if _is_plugin_root(root) else root / PLUGIN_NAME
- def _repo_root(root: Path) -> Path:
- if _is_plugin_root(root) and (root.parent / ".claude-plugin" / "marketplace.json").is_file():
- return root.parent
- return root
- def _check_manifest(root: Path, issues: list[dict[str, str]]) -> tuple[str, str]:
- plugin_json = _plugin_root(root) / ".claude-plugin" / "plugin.json"
- payload, error = _load_json(plugin_json)
- if error:
- issues.append(_issue("manifest.plugin_json", message=error, path=str(plugin_json), repair="恢复 .claude-plugin/plugin.json。"))
- return "", ""
- name = str(payload.get("name") or "")
- version = str(payload.get("version") or "")
- if not KEBAB_RE.fullmatch(name):
- issues.append(_issue("manifest.name", message=f"invalid plugin name: {name}", path=str(plugin_json), repair="使用 kebab-case 插件名。"))
- if not SEMVER_RE.fullmatch(version):
- issues.append(_issue("manifest.version", message=f"invalid semver: {version}", path=str(plugin_json), repair="使用 X.Y.Z 版本号。"))
- if not str(payload.get("description") or "").strip():
- issues.append(_issue("manifest.description", message="plugin description missing", path=str(plugin_json), repair="补齐 description。"))
- return name, version
- def _check_marketplace(root: Path, plugin_version: str, issues: list[dict[str, str]]) -> None:
- marketplace = _repo_root(root) / ".claude-plugin" / "marketplace.json"
- payload, error = _load_json(marketplace)
- if error:
- severity = "warning" if _is_plugin_root(root) else "error"
- issues.append(
- _issue(
- "marketplace.json",
- message=error,
- severity=severity,
- path=str(marketplace),
- repair="在仓库根运行可校验 marketplace;插件根安装包可忽略该项。",
- )
- )
- return
- plugin = _marketplace_plugin(payload)
- if plugin is None:
- issues.append(_issue("marketplace.plugin", message=f"{PLUGIN_NAME} missing from marketplace", path=str(marketplace), repair="在 plugins[] 中加入 webnovel-writer。"))
- return
- if plugin.get("source") != "./webnovel-writer":
- issues.append(_issue("marketplace.source", message=f"unexpected source: {plugin.get('source')}", path=str(marketplace), repair="source 应为 ./webnovel-writer。"))
- marketplace_version = str(plugin.get("version") or "")
- if plugin_version and marketplace_version != plugin_version:
- issues.append(
- _issue(
- "version.marketplace",
- message=f"plugin.json={plugin_version}, marketplace.json={marketplace_version}",
- path=str(marketplace),
- repair="运行 sync_plugin_version.py --version X.Y.Z --release-notes ...。",
- )
- )
- def _check_readme_version(root: Path, plugin_version: str, issues: list[dict[str, str]]) -> None:
- if _is_plugin_root(root):
- candidates = [_repo_root(root) / "README.md", root / "README.md"]
- else:
- candidates = [root / "README.md", _plugin_root(root) / "README.md"]
- readme = next((candidate for candidate in candidates if candidate.is_file()), candidates[0])
- try:
- content = readme.read_text(encoding="utf-8")
- readme_version = sync_plugin_version.get_readme_current_version(content)
- except Exception as exc:
- issues.append(_issue("version.readme.parse", message=str(exc), path=str(readme), repair="保持 README 版本表格式与 sync_plugin_version.py 一致。"))
- return
- if plugin_version and readme_version != plugin_version:
- issues.append(
- _issue(
- "version.readme",
- message=f"plugin.json={plugin_version}, README.md={readme_version}",
- path=str(readme),
- repair="运行 sync_plugin_version.py --version X.Y.Z --release-notes ...。",
- )
- )
- def _check_frontmatter(root: Path, issues: list[dict[str, str]]) -> None:
- plugin_root = _plugin_root(root)
- for skill in sorted((plugin_root / "skills").glob("*/SKILL.md")):
- fm = _frontmatter(skill)
- for field in ("name", "description"):
- if not fm.get(field):
- issues.append(_issue("skill.frontmatter", message=f"skill missing {field}", path=str(skill), repair="按 plugin-dev skill-development 补齐 frontmatter。"))
- for agent in sorted((plugin_root / "agents").glob("*.md")):
- fm = _frontmatter(agent)
- for field in ("name", "description", "tools"):
- if not fm.get(field):
- issues.append(_issue("agent.frontmatter", message=f"agent missing {field}", path=str(agent), repair="按 plugin-dev agent-development 补齐 frontmatter。"))
- def _check_optional_assets(root: Path, issues: list[dict[str, str]]) -> None:
- plugin_root = _plugin_root(root)
- if not (plugin_root / "LICENSE").is_file():
- issues.append(_issue("license", message="LICENSE missing", severity="error", path=str(plugin_root / "LICENSE"), repair="恢复插件 LICENSE。"))
- dashboard_dist = plugin_root / "dashboard" / "frontend" / "dist"
- if not dashboard_dist.is_dir():
- issues.append(_issue("dashboard.dist", message="dashboard frontend dist missing", severity="warning", path=str(dashboard_dist), repair="发布前运行 dashboard 前端 build 并包含 dist。"))
- hooks_json = plugin_root / "hooks" / "hooks.json"
- if hooks_json.exists():
- payload, error = _load_json(hooks_json)
- if error:
- issues.append(_issue("hooks.schema", message=error, path=str(hooks_json), repair="修复 hooks/hooks.json。"))
- elif "description" not in payload or "hooks" not in payload:
- issues.append(_issue("hooks.wrapper", message="hooks.json should use plugin-dev wrapper format", path=str(hooks_json), repair="外层包含 description 与 hooks。"))
- def _check_portability(root: Path, issues: list[dict[str, str]]) -> None:
- plugin_root = _plugin_root(root)
- targets = list((plugin_root / "skills").glob("*/SKILL.md"))
- targets.extend((plugin_root / "agents").glob("*.md"))
- targets.extend((plugin_root / ".claude-plugin").glob("*.json"))
- hooks_root = plugin_root / "hooks"
- if hooks_root.is_dir():
- targets.extend(path for path in hooks_root.rglob("*") if path.suffix in {".json", ".py", ".sh", ".md"})
- for path in targets:
- try:
- text = path.read_text(encoding="utf-8")
- except OSError:
- continue
- if LOCAL_ABSOLUTE_RE.search(text):
- issues.append(
- _issue(
- "portability.local_absolute_path",
- message="local absolute path found in plugin component",
- severity="warning",
- path=str(path),
- repair="插件组件内使用 ${CLAUDE_PLUGIN_ROOT} 或相对路径。",
- )
- )
- def validate_package(root: str | Path | None = None, *, strict: bool = False) -> dict[str, Any]:
- repo_root = Path(root) if root is not None else Path(__file__).resolve().parent.parent.parent
- issues: list[dict[str, str]] = []
- _, plugin_version = _check_manifest(repo_root, issues)
- _check_marketplace(repo_root, plugin_version, issues)
- _check_readme_version(repo_root, plugin_version, issues)
- _check_frontmatter(repo_root, issues)
- _check_optional_assets(repo_root, issues)
- _check_portability(repo_root, issues)
- blocking = [
- item for item in issues if item["severity"] == "error" or (strict and item["severity"] == "warning")
- ]
- return {
- "schema_version": SCHEMA_VERSION,
- "ok": not blocking,
- "strict": strict,
- "root": str(repo_root),
- "error_count": sum(1 for item in issues if item["severity"] == "error"),
- "warning_count": sum(1 for item in issues if item["severity"] == "warning"),
- "issues": issues,
- }
- def format_report(report: dict[str, Any], output_format: str = "text") -> str:
- if output_format == "json":
- return json.dumps(report, ensure_ascii=False, indent=2)
- status = "OK" if report.get("ok") else "ERROR"
- lines = [
- f"{status} plugin package",
- f"errors: {report.get('error_count')} warnings: {report.get('warning_count')}",
- ]
- for item in report.get("issues") or []:
- lines.append(f"{item.get('severity', '').upper()} {item.get('code')}: {item.get('message')}")
- if item.get("path"):
- lines.append(f" path: {item.get('path')}")
- if item.get("repair"):
- lines.append(f" repair: {item.get('repair')}")
- return "\n".join(lines)
- def main() -> int:
- parser = argparse.ArgumentParser(description="Validate webnovel-writer plugin package metadata and components")
- parser.add_argument("--root", default="", help="仓库根目录,默认自动推断")
- parser.add_argument("--strict", action="store_true", help="warning 也视为失败")
- parser.add_argument("--format", choices=["text", "json"], default="text")
- args = parser.parse_args()
- report = validate_package(args.root or None, strict=args.strict)
- print(format_report(report, args.format))
- return 0 if report.get("ok") else 1
- if __name__ == "__main__":
- raise SystemExit(main())
|