303 lines
11 KiB
Python
303 lines
11 KiB
Python
#!/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 必须是非空列表")
|
||
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())
|