#!/usr/bin/env python3 """Validate and inspect a ship test manifest.""" from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any from ship_lib import load_yaml POLICIES = {"required", "not_applicable"} CHANGES = {"added", "changed"} KINDS = {"unit", "integration", "e2e", "static"} MODES = {"schema", "verify", "regress"} def _issue(issues: list[str], message: str) -> None: issues.append(message) def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None: if not isinstance(value, dict): _issue(issues, f"{path} 必须是 mapping") return None return value def validate_manifest( manifest_path: Path, *, expected_ship_id: str | None = None, policy: str = "required", mode: str = "schema", ) -> tuple[list[str], dict[str, Any] | None]: issues: list[str] = [] if mode not in MODES: return [f"mode 无效: {mode!r}"], None try: data = load_yaml(manifest_path) except Exception as exc: # noqa: BLE001 return [f"无法解析 test manifest: {exc}"], None root = _mapping(data, "test-manifest", issues) if root is None: return issues, None ship_id = root.get("shipId") if not isinstance(ship_id, str) or not ship_id.strip(): _issue(issues, "test-manifest.shipId 不能为空") elif expected_ship_id and ship_id != expected_ship_id: _issue(issues, f"test-manifest.shipId 不一致: {ship_id!r} != {expected_ship_id!r}") tests = root.get("tests") if not isinstance(tests, list): _issue(issues, "test-manifest.tests 必须是列表") tests = [] ids: set[str] = set() for index, item in enumerate(tests): prefix = f"tests[{index}]" row = _mapping(item, prefix, issues) if row is None: continue test_id = row.get("id") if not isinstance(test_id, str) or not test_id.strip(): _issue(issues, f"{prefix}.id 不能为空") elif test_id in ids: _issue(issues, f"重复测试 id: {test_id}") else: ids.add(test_id) for field in ("runner", "target", "selector"): if not isinstance(row.get(field), str) or not row[field].strip(): _issue(issues, f"{prefix}.{field} 不能为空") if row.get("kind") not in KINDS: _issue(issues, f"{prefix}.kind 无效: {row.get('kind')!r}") if row.get("change") not in CHANGES: _issue(issues, f"{prefix}.change 无效: {row.get('change')!r}") covers = row.get("covers") if not isinstance(covers, list) or not covers or not all( isinstance(value, str) and value.strip() for value in covers ): _issue(issues, f"{prefix}.covers 必须是非空字符串列表") if policy not in POLICIES: _issue(issues, f"test policy 无效: {policy!r}") gap = root.get("testGap") if gap is not None: gap_row = _mapping(gap, "testGap", issues) if gap_row is not None: if not isinstance(gap_row.get("reason"), str) or not gap_row["reason"].strip(): _issue(issues, "testGap.reason 不能为空") if mode in {"verify", "regress"} and not ( isinstance(gap_row.get("acceptedBy"), str) and gap_row["acceptedBy"].strip() ): _issue(issues, "testGap 在 verify/regress 前必须有 acceptedBy") if mode in {"verify", "regress"}: if policy == "required" and not tests and gap is None: _issue(issues, "policy=required 时必须登记至少一个测试,或填写已批准的 testGap") if policy == "not_applicable" and tests: _issue(issues, "policy=not_applicable 时 tests 必须为空") if policy == "not_applicable" and gap is None: _issue(issues, "policy=not_applicable 时必须说明 testGap.reason") return issues, root def main() -> int: parser = argparse.ArgumentParser(description="校验并列出 ship 测试 manifest") parser.add_argument("manifest", help="test-manifest.yaml 路径") parser.add_argument("--ship-id") parser.add_argument("--policy", default="required", choices=sorted(POLICIES)) parser.add_argument("--mode", default="schema", choices=sorted(MODES)) parser.add_argument("--json", action="store_true", dest="as_json") args = parser.parse_args() issues, data = validate_manifest( Path(args.manifest), expected_ship_id=args.ship_id, policy=args.policy, mode=args.mode, ) if args.as_json: print(json.dumps({"ok": not issues, "issues": issues, "tests": (data or {}).get("tests", [])}, ensure_ascii=False)) elif issues: for issue in issues: print(f"✗ {issue}", file=sys.stderr) else: print(f"✓ test manifest 通过: {args.manifest}") for row in (data or {}).get("tests", []): print(f"- {row['id']}: {row['runner']} {row['target']} {row['selector']}") return 1 if issues else 0 if __name__ == "__main__": raise SystemExit(main())