#!/usr/bin/env python3 """Validate a .pouch/ship// 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, 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/ 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 不能为空") 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 必须是非空列表") 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/ 或其中的 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())