Files
.pouch/skills/ship/scripts/validate_ship.py
T
2026-10-14 12:14:31 +08:00

316 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Validate a .pouch/ship/<id>/ record. Exit 0 on success, 1 on issues."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import (
COMMIT_ROLE,
DELIVERY_STATE,
KIND,
SHIP_STATUS,
STOP_AT,
SOURCE_KIND,
TASK_STATUS,
check_ship_id,
load_yaml,
)
def _issue(issues: list[str], msg: str) -> None:
issues.append(msg)
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
if value is None:
_issue(issues, f"{path} 缺失")
return None
if not isinstance(value, dict):
_issue(issues, f"{path} 必须是 mapping")
return None
return value
def _project_root(ship_dir: Path) -> Path:
# .pouch/ship/<id>
return ship_dir.parent.parent.parent
def validate_ship_dir(ship_dir: Path) -> list[str]:
issues: list[str] = []
ship_dir = ship_dir.resolve()
current_path = ship_dir / "current.yaml"
if not current_path.is_file():
return [f"缺少 {current_path}"]
try:
current = load_yaml(current_path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 current.yaml: {exc}"]
data = _mapping(current, "current.yaml", issues)
if data is None:
return issues
ship_id = data.get("shipId")
if not isinstance(ship_id, str):
_issue(issues, "current.yaml.shipId 必须是字符串")
else:
err = check_ship_id(ship_id)
if err:
_issue(issues, err)
if ship_id != ship_dir.name:
_issue(
issues,
f"目录名与 shipId 不一致: {ship_dir.name} != {ship_id}",
)
kind = data.get("kind")
if kind not in KIND:
_issue(issues, f"kind 无效: {kind!r}(short|long)")
status = data.get("status")
if status not in SHIP_STATUS:
_issue(issues, f"status 无效: {status!r}")
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
_issue(issues, "title 不能为空")
for path, value in (
("stopAt", data.get("stopAt")),
("delivery.stopAt", (data.get("delivery") or {}).get("stopAt")
if isinstance(data.get("delivery"), dict) else None),
):
if value not in (None, "") and value not in STOP_AT:
_issue(
issues,
f"{path} 无效: {value!r}({', '.join(sorted(STOP_AT))})",
)
source = data.get("source")
if source is not None:
src = _mapping(source, "source", issues)
if src is not None and src.get("kind") not in SOURCE_KIND:
_issue(issues, f"source.kind 无效: {src.get('kind')!r}")
spec = _mapping(data.get("spec"), "spec", issues)
spec_path = ship_dir / "spec.md"
if not spec_path.is_file():
_issue(issues, "缺少 spec.md")
if spec is not None:
rel = spec.get("path") or "spec.md"
if rel != "spec.md":
_issue(issues, f"spec.path 必须是 spec.md,实际: {rel}")
if kind == "long":
spec_dir = ship_dir / "spec"
if not spec_dir.is_dir():
_issue(issues, "kind=long 缺少 spec/ 目录")
revision = spec.get("revision")
prd = spec.get("prd")
if revision and not prd:
_issue(issues, "long spec.revision 已设但 spec.prd 为空")
if prd:
prd_file = ship_dir / str(prd)
if not prd_file.is_file():
_issue(issues, f"缺少 PRD 文件: {prd}")
handoff = spec.get("handoff")
spec_status = spec.get("specStatus")
if spec_status == "approved" and not handoff:
_issue(issues, "specStatus=approved 但缺少 spec.handoff")
if handoff:
handoff_file = ship_dir / str(handoff)
if not handoff_file.is_file():
_issue(issues, f"缺少 handoff 文件: {handoff}")
elif kind == "short":
for field in (
"revision",
"specStatus",
"prd",
"approvedRevision",
"reviews",
"handoff",
):
if spec.get(field) not in (None, "", []):
_issue(issues, f"kind=short 时 spec.{field} 必须为空")
tasks_meta = _mapping(data.get("tasks"), "tasks", issues)
tasks_rel = "tasks.yaml"
if tasks_meta is not None:
tasks_rel = str(tasks_meta.get("path") or "tasks.yaml")
tasks_path = ship_dir / tasks_rel
if not tasks_path.is_file():
_issue(issues, f"缺少 {tasks_rel}")
else:
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
delivery = data.get("delivery")
if delivery is not None:
deliv = _mapping(delivery, "delivery", issues)
if deliv is not None:
state = deliv.get("state")
if state not in DELIVERY_STATE:
_issue(issues, f"delivery.state 无效: {state!r}")
manager = data.get("manager")
if manager is not None:
mgr = _mapping(manager, "manager", issues)
if mgr is not None:
result = mgr.get("lastResult")
if result not in (None, "pass", "fail", "blocked", "ask"):
_issue(issues, f"manager.lastResult 无效: {result!r}")
in_flight = mgr.get("inFlight")
if in_flight not in (None, "") and not isinstance(in_flight, str):
_issue(issues, "manager.inFlight 必须是字符串或空")
evidence = _mapping(data.get("evidence"), "evidence", issues)
if evidence is not None:
issues.extend(_validate_evidence(evidence, _project_root(ship_dir)))
status_md = ship_dir / "status.md"
if not status_md.is_file():
_issue(issues, "缺少 status.md")
return issues
def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]:
issues: list[str] = []
try:
data = load_yaml(path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 tasks.yaml: {exc}"]
mapping = _mapping(data, "tasks.yaml", issues)
if mapping is None:
return issues
if mapping.get("shipId") != ship_id:
_issue(
issues,
f"tasks.yaml.shipId 与 current.yaml 不一致: "
f"{mapping.get('shipId')!r} != {ship_id!r}",
)
tasks = mapping.get("tasks")
if not isinstance(tasks, list) or not tasks:
_issue(issues, "tasks.yaml.tasks 必须是非空列表")
return issues
if kind == "short" and len(tasks) != 1:
_issue(issues, f"kind=short 必须恰好 1 条任务,实际 {len(tasks)}")
ids: list[str] = []
for i, item in enumerate(tasks):
prefix = f"tasks[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
tid = row.get("id")
if not isinstance(tid, str) or not tid.strip():
_issue(issues, f"{prefix}.id 不能为空")
else:
if tid in ids:
_issue(issues, f"重复任务 id: {tid}")
ids.append(tid)
if not isinstance(row.get("title"), str) or not str(row.get("title")).strip():
_issue(issues, f"{prefix}.title 不能为空")
depends = row.get("dependsOn") or []
if not isinstance(depends, list):
_issue(issues, f"{prefix}.dependsOn 必须是列表")
status = row.get("status")
if status not in TASK_STATUS:
_issue(issues, f"{prefix}.status 无效: {status!r}")
expected = row.get("expected")
if not isinstance(expected, list) or not expected:
_issue(issues, f"{prefix}.expected 必须是非空列表")
verification = row.get("verification")
if not isinstance(verification, list) or not verification:
_issue(issues, f"{prefix}.verification 必须是非空列表")
test_plan = row.get("testPlan")
if test_plan is not None:
plan = _mapping(test_plan, f"{prefix}.testPlan", issues)
if plan is not None:
policy = plan.get("policy")
if policy not in {"required", "not_applicable"}:
_issue(
issues,
f"{prefix}.testPlan.policy 无效: {policy!r}(required|not_applicable)",
)
manifest = plan.get("manifest")
if not isinstance(manifest, str) or not manifest.strip():
_issue(issues, f"{prefix}.testPlan.manifest 不能为空")
for i, item in enumerate(tasks):
if not isinstance(item, dict):
continue
for dep in item.get("dependsOn") or []:
if dep not in ids:
_issue(issues, f"tasks[{i}].dependsOn 引用未知 id: {dep}")
if dep == item.get("id"):
_issue(issues, f"tasks[{i}] 不能依赖自己")
return issues
def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]:
issues: list[str] = []
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
value = evidence.get(key)
if value is None:
_issue(issues, f"evidence.{key} 缺失")
continue
if not isinstance(value, list):
_issue(issues, f"evidence.{key} 必须是列表")
continue
for i, item in enumerate(value):
prefix = f"evidence.{key}[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
if key == "testRuns":
rel = row.get("path")
if not isinstance(rel, str) or not rel.strip():
_issue(issues, f"{prefix}.path 不能为空")
else:
target = project_root / rel
if not target.is_file():
_issue(issues, f"{prefix}.path 不存在: {rel}")
if row.get("result") not in {
"pass",
"fail",
"environment-fail",
"skipped",
"not-run",
"partial",
}:
_issue(issues, f"{prefix}.result 无效: {row.get('result')!r}")
elif key == "artifacts":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
elif key == "deploys":
if not row.get("env"):
_issue(issues, f"{prefix}.env 不能为空")
elif key == "prs":
if not row.get("url"):
_issue(issues, f"{prefix}.url 不能为空")
elif key == "commits":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
if row.get("role") not in COMMIT_ROLE:
_issue(issues, f"{prefix}.role 无效: {row.get('role')!r}")
return issues
def main() -> int:
parser = argparse.ArgumentParser(description="校验 ship 记录目录")
parser.add_argument("path", help=".pouch/ship/<id> 或其中的 current.yaml")
args = parser.parse_args()
path = Path(args.path)
ship_dir = path.parent if path.name == "current.yaml" else path
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ ship 记录通过: {ship_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())