#!/usr/bin/env python3 """校验 ACK 项目回归目录及其任务引用。 权威结构位于 templates/regression.schema.json。jsonschema 是可选依赖;内置规则 始终检查用例 ID、验收信号和跨文件引用。本脚本只解析数据,不执行 steps。 用法: python3 validate_regression.py docs/ack/regression.yaml python3 validate_regression.py docs/ack/regression.yaml \ --tasks docs/ack/tasks.yaml 退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。 """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any from yaml_subset import ( DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader, ) ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$") TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") STATUSES = {"active", "retired"} SUITES = {"smoke", "full"} SURFACES = {"browser", "api"} SOURCE_KINDS = {"feature", "bug"} EXPECTED_KINDS = {"visible-text", "api-status", "api-field", "url", "interaction"} CASE_FIELDS = { "id", "title", "status", "source", "suite", "surface", "setup", "steps", "expected", "automationRef", } SOURCE_FIELDS = {"taskId", "kind"} EXPECTED_FIELDS = {"kind", "value"} TOP_LEVEL_FIELDS = {"version", "updatedAt", "project", "cases"} def _nonempty(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) def _mapping(value: Any) -> bool: return isinstance(value, dict) def _reject_unknown( value: dict[str, Any], allowed: set[str], where: str, errors: list[str], ) -> None: for field in sorted(set(value) - allowed): errors.append(f"{where}: 未知字段 {field!r}") def load_yaml(path: Path, label: str) -> dict[str, Any]: try: content = path.read_text(encoding="utf-8") except OSError as exc: sys.stderr.write(f"{label}读取失败: {exc}\n") raise SystemExit(1) if path.suffix.lower() == ".json": try: data = load_json_unique(content) except (json.JSONDecodeError, DuplicateKeyError) as exc: sys.stderr.write(f"{label} JSON 解析失败: {exc}\n") raise SystemExit(1) else: try: import yaml # type: ignore except ImportError: try: data = load_yaml_subset(content) except YamlSubsetError as exc: sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n") raise SystemExit(1) else: try: data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml)) except yaml.YAMLError as exc: # type: ignore sys.stderr.write(f"{label} YAML 解析失败: {exc}\n") raise SystemExit(1) if not isinstance(data, dict): sys.stderr.write(f"{label}顶层必须是对象(mapping)\n") raise SystemExit(1) return data def infer_project_root(document_path: Path) -> Path | None: lexical = document_path.expanduser().absolute() parent = lexical.parent if parent.name == "ack" and parent.parent.name == "docs": return parent.parent.parent.resolve() for candidate in (parent, *parent.parents): if (candidate / ".git").exists(): return candidate.resolve() return None def _validate_expected(value: Any, where: str, errors: list[str]) -> None: if not isinstance(value, list) or not value: errors.append(f"{where}: 必须是非空列表") return for index, item in enumerate(value): item_where = f"{where}[{index}]" if not _mapping(item): errors.append(f"{item_where}: 必须是对象") continue _reject_unknown(item, EXPECTED_FIELDS, item_where, errors) if item.get("kind") not in EXPECTED_KINDS: errors.append(f"{item_where}.kind: 必须是 {sorted(EXPECTED_KINDS)}") if not _nonempty(item.get("value")): errors.append(f"{item_where}.value: 必须是非空字符串") def validate_builtin(data: dict[str, Any]) -> list[str]: errors: list[str] = [] _reject_unknown(data, TOP_LEVEL_FIELDS, "", errors) if data.get("version") != 1 or isinstance(data.get("version"), bool): errors.append("version 必须是整数 1") if not _nonempty(data.get("updatedAt")): errors.append("updatedAt 必须是非空字符串") project = data.get("project") if not _mapping(project): errors.append("project 必须是对象") else: _reject_unknown(project, {"name"}, "project", errors) if not _nonempty(project.get("name")): errors.append("project.name 必须是非空字符串") cases = data.get("cases") if not isinstance(cases, list): errors.append("cases 必须是列表") return errors seen: set[str] = set() for index, case in enumerate(cases): where = f"cases[{index}]" if not _mapping(case): errors.append(f"{where}: 必须是对象") continue _reject_unknown(case, CASE_FIELDS, where, errors) missing = sorted(CASE_FIELDS - {"automationRef"} - set(case)) for field in missing: errors.append(f"{where}.{field}: 必填") case_id = case.get("id") if not isinstance(case_id, str) or ID_RE.fullmatch(case_id) is None: errors.append(f"{where}.id: 必须使用 REG- 格式") elif case_id in seen: errors.append(f"{where}.id: 不能重复 {case_id!r}") else: seen.add(case_id) if not _nonempty(case.get("title")): errors.append(f"{where}.title: 必须是非空字符串") if case.get("status") not in STATUSES: errors.append(f"{where}.status: 必须是 {sorted(STATUSES)}") if case.get("suite") not in SUITES: errors.append(f"{where}.suite: 必须是 {sorted(SUITES)}") if case.get("surface") not in SURFACES: errors.append(f"{where}.surface: 必须是 {sorted(SURFACES)}") if not _nonempty(case.get("setup")): errors.append(f"{where}.setup: 必须是非空字符串") source = case.get("source") if not _mapping(source): errors.append(f"{where}.source: 必须是对象") else: _reject_unknown(source, SOURCE_FIELDS, f"{where}.source", errors) task_id = source.get("taskId") if not isinstance(task_id, str) or TASK_ID_RE.fullmatch(task_id) is None: errors.append(f"{where}.source.taskId: 必须是任务 ID") if source.get("kind") not in SOURCE_KINDS: errors.append(f"{where}.source.kind: 必须是 {sorted(SOURCE_KINDS)}") steps = case.get("steps") if ( not isinstance(steps, list) or not steps or any(not _nonempty(step) for step in steps) ): errors.append(f"{where}.steps: 必须是非空字符串列表") _validate_expected(case.get("expected"), f"{where}.expected", errors) automation_ref = case.get("automationRef") if automation_ref is not None: if ( not isinstance(automation_ref, str) or automation_ref.startswith("/") or "\\" in automation_ref or ".." in automation_ref.split("/") or RELATIVE_PATH_RE.fullmatch(automation_ref) is None ): errors.append(f"{where}.automationRef: 必须是项目内相对路径") return errors def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]: import jsonschema # type: ignore schema = json.loads(schema_path.read_text(encoding="utf-8")) validator = jsonschema.Draft7Validator(schema) errors = [] for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)): location = "/".join(str(part) for part in error.path) or "" errors.append(f"[schema] {location}: {error.message}") return errors def validate_tasks_link( regression: dict[str, Any], tasks: dict[str, Any], ) -> list[str]: errors: list[str] = [] project = tasks.get("project") if not isinstance(project, dict): return ["[tasks] project 必须是对象"] regression_file = project.get("regressionFile") if regression_file != "docs/ack/regression.yaml": errors.append( "[tasks] project.regressionFile 必须固定为 docs/ack/regression.yaml" ) if not isinstance(tasks.get("regressionRuns"), list): errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表") regression_project = regression.get("project") if ( isinstance(regression_project, dict) and _nonempty(regression_project.get("name")) and _nonempty(project.get("name")) and regression_project["name"] != project["name"] ): errors.append("regression.project.name 必须与 tasks.project.name 一致") case_ids = { case.get("id") for case in regression.get("cases") or [] if isinstance(case, dict) and isinstance(case.get("id"), str) } task_ids = { task.get("id") for task in tasks.get("tasks") or [] if isinstance(task, dict) and isinstance(task.get("id"), str) } for index, task in enumerate(tasks.get("tasks") or []): if not isinstance(task, dict): continue where = f"[tasks] tasks[{index}]" refs = task.get("regressionRefs") if refs is None: continue if not isinstance(refs, list): errors.append(f"{where}.regressionRefs: 必须是列表") continue seen: set[str] = set() for ref_index, ref in enumerate(refs): ref_where = f"{where}.regressionRefs[{ref_index}]" if not isinstance(ref, str) or ID_RE.fullmatch(ref) is None: errors.append(f"{ref_where}: 必须使用 REG- 格式") continue if ref in seen: errors.append(f"{ref_where}: 不能重复 {ref!r}") seen.add(ref) if ref not in case_ids: errors.append(f"{ref_where}: 未知用例 {ref!r}") for index, run in enumerate(tasks.get("regressionRuns") or []): if not isinstance(run, dict): continue where = f"[tasks] regressionRuns[{index}]" for case_id in run.get("caseIds") or []: if isinstance(case_id, str) and case_id not in case_ids: errors.append(f"{where}.caseIds: 未知用例 {case_id!r}") source_task_ids = run.get("taskIds") or [] if isinstance(source_task_ids, list): for task_id in source_task_ids: if isinstance(task_id, str) and task_id not in task_ids: errors.append(f"{where}.taskIds: 未知任务 {task_id!r}") return errors def validate_all( data: dict[str, Any], schema_path: Path, *, use_schema: bool | None = None, ) -> tuple[list[str], str]: if use_schema is None: try: import jsonschema # type: ignore # noqa: F401 except ImportError: use_schema = False else: use_schema = schema_path.is_file() errors = validate_builtin(data) if use_schema and schema_path.is_file(): errors = validate_with_schema(data, schema_path) + errors mode = f"schema ({schema_path.name}) + 内置结构" else: mode = "内置结构" return list(dict.fromkeys(errors)), mode def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="校验 ACK 项目回归目录") parser.add_argument( "regression", nargs="?", default="docs/ack/regression.yaml" ) parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml") parser.add_argument("--schema", help="regression.schema.json 路径(默认自动探测)") args = parser.parse_args(argv) regression_path = Path(args.regression) if not regression_path.is_file(): sys.stderr.write(f"找不到回归目录: {regression_path}\n") return 2 schema_path = ( Path(args.schema) if args.schema else Path(__file__).resolve().parent.parent / "templates" / "regression.schema.json" ) if args.schema and not schema_path.is_file(): sys.stderr.write(f"找不到 schema: {schema_path}\n") return 2 data = load_yaml(regression_path, "回归目录") errors, mode = validate_all(data, schema_path) if args.tasks: tasks_path = Path(args.tasks) if not tasks_path.is_file(): sys.stderr.write(f"找不到任务板: {tasks_path}\n") return 2 tasks = load_yaml(tasks_path, "任务板") errors.extend(validate_tasks_link(data, tasks)) mode += " + tasks 引用" if errors: sys.stderr.write(f"回归目录校验失败({mode}),共 {len(errors)} 项:\n") for error in errors: sys.stderr.write(f"- {error}\n") return 1 sys.stdout.write(f"回归目录校验通过({mode})\n") return 0 if __name__ == "__main__": raise SystemExit(main())