Merge branch 'main' into rename
Keep pouch naming and .pouch/ack project state, and bring in ACK regression mode, deployer test-environment binding, and manage-release updates from main.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""选择 ACK 回归目录中的 active 用例。
|
||||
|
||||
默认返回 smoke 套件。本脚本只输出数据,不执行 steps 或 automationRef。
|
||||
|
||||
用法:
|
||||
python3 select_regression.py .pouch/ack/regression.yaml
|
||||
python3 select_regression.py .pouch/ack/regression.yaml --suite full
|
||||
python3 select_regression.py .pouch/ack/regression.yaml --case-id REG-login-001
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from validate_regression import ID_RE, load_yaml, validate_builtin
|
||||
|
||||
DEFAULT_LIMIT = 50
|
||||
MAX_LIMIT = 200
|
||||
|
||||
|
||||
def select_cases(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
suite: str,
|
||||
case_ids: list[str],
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
cases = data.get("cases")
|
||||
if not isinstance(cases, list):
|
||||
raise ValueError("cases 必须是列表")
|
||||
|
||||
wanted = set(case_ids)
|
||||
selected: list[dict[str, Any]] = []
|
||||
for case in cases:
|
||||
if not isinstance(case, dict):
|
||||
continue
|
||||
if case.get("status") != "active":
|
||||
continue
|
||||
case_id = case.get("id")
|
||||
if wanted:
|
||||
if case_id not in wanted:
|
||||
continue
|
||||
elif suite == "smoke" and case.get("suite") != "smoke":
|
||||
continue
|
||||
selected.append(case)
|
||||
|
||||
missing = wanted - {
|
||||
case.get("id") for case in selected if isinstance(case.get("id"), str)
|
||||
}
|
||||
if missing:
|
||||
raise ValueError("找不到 active 用例: " + ", ".join(sorted(missing)))
|
||||
if len(selected) > limit:
|
||||
raise ValueError(
|
||||
f"命中 {len(selected)} 条,超过 --limit {limit};"
|
||||
"请用 --suite / --case-id 缩小范围"
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="选择 ACK 回归用例")
|
||||
parser.add_argument(
|
||||
"regression", nargs="?", default=".pouch/ack/regression.yaml"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--suite",
|
||||
choices=("smoke", "full"),
|
||||
default="smoke",
|
||||
help="smoke 只返回 suite=smoke;full 返回全部 active 用例",
|
||||
)
|
||||
parser.add_argument("--case-id", action="append", default=[], dest="case_ids")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument(
|
||||
"--format", choices=("json", "ids"), default="json", dest="output_format"
|
||||
)
|
||||
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
|
||||
if not 1 <= args.limit <= MAX_LIMIT:
|
||||
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
|
||||
return 2
|
||||
for case_id in args.case_ids:
|
||||
if ID_RE.fullmatch(case_id) is None:
|
||||
sys.stderr.write(f"--case-id 必须使用 REG-<id> 格式: {case_id}\n")
|
||||
return 2
|
||||
|
||||
data = load_yaml(regression_path, "回归目录")
|
||||
errors = validate_builtin(data)
|
||||
if errors:
|
||||
sys.stderr.write(f"回归目录无效,拒绝选择,共 {len(errors)} 项:\n")
|
||||
for error in errors:
|
||||
sys.stderr.write(f" - {error}\n")
|
||||
return 1
|
||||
|
||||
try:
|
||||
selected = select_cases(
|
||||
data,
|
||||
suite=args.suite,
|
||||
case_ids=args.case_ids,
|
||||
limit=args.limit,
|
||||
)
|
||||
except ValueError as exc:
|
||||
sys.stderr.write(f"回归选择失败: {exc}\n")
|
||||
return 1
|
||||
|
||||
ids = [case.get("id") for case in selected]
|
||||
if args.output_format == "ids":
|
||||
if ids:
|
||||
sys.stdout.write("\n".join(str(item) for item in ids) + "\n")
|
||||
return 0
|
||||
|
||||
payload = {
|
||||
"count": len(selected),
|
||||
"limit": args.limit,
|
||||
"suite": args.suite,
|
||||
"ids": ids,
|
||||
"cases": selected,
|
||||
}
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -102,9 +102,9 @@ CLASSIFICATIONS = {"development", "staging", "production"}
|
||||
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||
INTENT_FIELDS = {"testEnvironment", "release"}
|
||||
INTENT_STOP_AT = {
|
||||
"testEnvironment": "validation_ready",
|
||||
"release": "released",
|
||||
}
|
||||
TEST_ENVIRONMENT_FIELDS = {"via", "env"}
|
||||
ACTIONS = {
|
||||
"verify",
|
||||
"pull-request",
|
||||
@@ -681,10 +681,47 @@ def _validate_profiles(
|
||||
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
|
||||
|
||||
|
||||
def _validate_test_environment_intent(
|
||||
value: Any,
|
||||
errors: list[str],
|
||||
project_root: Path | None,
|
||||
) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
errors.append(
|
||||
"intents.testEnvironment: 已改为 deployer 绑定 "
|
||||
"{via: deployer, env: <env>},不能再使用 profile ID "
|
||||
f"{value!r}。请迁移后由 ACK 内部调用 deployer skill"
|
||||
)
|
||||
return
|
||||
if not _mapping(value):
|
||||
errors.append("intents.testEnvironment: 必须是 null 或 {via, env} 对象")
|
||||
return
|
||||
_reject_unknown(value, TEST_ENVIRONMENT_FIELDS, "intents.testEnvironment", errors)
|
||||
if value.get("via") != "deployer":
|
||||
errors.append("intents.testEnvironment.via 必须是 'deployer'")
|
||||
env = value.get("env")
|
||||
if not isinstance(env, str) or ID_RE.fullmatch(env) is None:
|
||||
errors.append("intents.testEnvironment.env 必须是小写连字符环境名")
|
||||
return
|
||||
if project_root is None:
|
||||
return
|
||||
env_dir = project_root / ".pouch" / "deployer" / env
|
||||
if not env_dir.is_dir():
|
||||
env_dir = project_root / ".skiff" / "deployer" / env
|
||||
if not env_dir.is_dir():
|
||||
errors.append(
|
||||
"intents.testEnvironment.env: 找不到 "
|
||||
f".pouch/deployer/{env};先按 deployer skill 配置项目测试环境"
|
||||
)
|
||||
|
||||
|
||||
def _validate_intents(
|
||||
values: Any,
|
||||
profiles: dict[str, Any],
|
||||
errors: list[str],
|
||||
project_root: Path | None = None,
|
||||
) -> None:
|
||||
if values is None:
|
||||
return
|
||||
@@ -692,25 +729,29 @@ def _validate_intents(
|
||||
errors.append("intents: 必须是对象")
|
||||
return
|
||||
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
|
||||
for field in sorted(INTENT_FIELDS):
|
||||
if field not in values:
|
||||
errors.append(f"intents.{field}: 必填")
|
||||
continue
|
||||
profile_id = values[field]
|
||||
if profile_id is None:
|
||||
continue
|
||||
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
||||
errors.append(f"intents.{field}: 必须是 null 或小写连字符 profile ID")
|
||||
continue
|
||||
profile = profiles.get(profile_id)
|
||||
if profile is None:
|
||||
errors.append(f"intents.{field}: 未定义 profile {profile_id!r}")
|
||||
continue
|
||||
expected_stop = INTENT_STOP_AT[field]
|
||||
if _mapping(profile) and profile.get("stopAt") != expected_stop:
|
||||
errors.append(
|
||||
f"intents.{field}: profile {profile_id!r} 必须 stopAt {expected_stop}"
|
||||
)
|
||||
if "testEnvironment" not in values:
|
||||
errors.append("intents.testEnvironment: 必填")
|
||||
else:
|
||||
_validate_test_environment_intent(
|
||||
values.get("testEnvironment"), errors, project_root
|
||||
)
|
||||
if "release" not in values:
|
||||
errors.append("intents.release: 必填")
|
||||
return
|
||||
profile_id = values["release"]
|
||||
if profile_id is None:
|
||||
return
|
||||
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
||||
errors.append("intents.release: 必须是 null 或小写连字符 profile ID")
|
||||
return
|
||||
profile = profiles.get(profile_id)
|
||||
if profile is None:
|
||||
errors.append(f"intents.release: 未定义 profile {profile_id!r}")
|
||||
return
|
||||
if _mapping(profile) and profile.get("stopAt") != INTENT_STOP_AT["release"]:
|
||||
errors.append(
|
||||
f"intents.release: profile {profile_id!r} 必须 stopAt released"
|
||||
)
|
||||
|
||||
|
||||
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
|
||||
@@ -758,7 +799,7 @@ def validate_builtin(data: dict[str, Any], project_root: Path | None = None) ->
|
||||
environments=environments,
|
||||
errors=errors,
|
||||
)
|
||||
_validate_intents(data.get("intents"), profiles, errors)
|
||||
_validate_intents(data.get("intents"), profiles, errors, project_root)
|
||||
|
||||
if enabled:
|
||||
if default_profile not in profiles:
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验 ACK 项目回归目录及其任务引用。
|
||||
|
||||
权威结构位于 templates/regression.schema.json。jsonschema 是可选依赖;内置规则
|
||||
始终检查用例 ID、验收信号和跨文件引用。本脚本只解析数据,不执行 steps。
|
||||
|
||||
用法:
|
||||
python3 validate_regression.py .pouch/ack/regression.yaml
|
||||
python3 validate_regression.py .pouch/ack/regression.yaml \
|
||||
--tasks .pouch/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, "<root>", 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-<id> 格式")
|
||||
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 "<root>"
|
||||
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 != ".pouch/ack/regression.yaml":
|
||||
errors.append(
|
||||
"[tasks] project.regressionFile 必须固定为 .pouch/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-<id> 格式")
|
||||
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=".pouch/ack/regression.yaml"
|
||||
)
|
||||
parser.add_argument("--tasks", help="关联的 .pouch/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())
|
||||
@@ -51,6 +51,8 @@ KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
REGRESSION_RUN_ID_RE = re.compile(r"^RR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
REGRESSION_CASE_ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
||||
SEMVER_RE = re.compile(
|
||||
@@ -117,6 +119,51 @@ DELIVERY_STATUSES = {
|
||||
}
|
||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||
REGRESSION_RUN_FIELDS = {
|
||||
"id",
|
||||
"suite",
|
||||
"caseIds",
|
||||
"status",
|
||||
"sourceRevision",
|
||||
"configRevision",
|
||||
"baseUrl",
|
||||
"results",
|
||||
"evidence",
|
||||
"updatedAt",
|
||||
}
|
||||
REGRESSION_RUN_OPTIONAL_FIELDS = {"deliveryRunId", "taskIds"}
|
||||
REGRESSION_RUN_SUITES = {"smoke", "full", "custom"}
|
||||
REGRESSION_RUN_STATUSES = {
|
||||
"planned",
|
||||
"running",
|
||||
"passed",
|
||||
"failed",
|
||||
"blocked",
|
||||
"skipped",
|
||||
}
|
||||
REGRESSION_RESULT_FIELDS = {"caseId", "result", "evidence"}
|
||||
REGRESSION_RESULTS = {"pass", "fail", "skipped"}
|
||||
REGRESSION_CANDIDATE_FIELDS = {
|
||||
"id",
|
||||
"title",
|
||||
"surface",
|
||||
"suite",
|
||||
"setup",
|
||||
"steps",
|
||||
"expected",
|
||||
"sourceKind",
|
||||
}
|
||||
REGRESSION_CANDIDATE_REQUIRED_FIELDS = {"title", "surface", "steps", "expected"}
|
||||
REGRESSION_SURFACES = {"browser", "api"}
|
||||
REGRESSION_SUITES = {"smoke", "full"}
|
||||
REGRESSION_SOURCE_KINDS = {"feature", "bug"}
|
||||
REGRESSION_EXPECTED_KINDS = {
|
||||
"visible-text",
|
||||
"api-status",
|
||||
"api-field",
|
||||
"url",
|
||||
"interaction",
|
||||
}
|
||||
FEISHU_REQUIRED_FIELDS = {
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||
"attachments", "updatedAt",
|
||||
@@ -636,6 +683,205 @@ def validate_delivery_runs(
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_regression_fields(task: dict, where: str, errors: list[str]) -> None:
|
||||
if "regressionRefs" in task:
|
||||
refs = task["regressionRefs"]
|
||||
if not isinstance(refs, list):
|
||||
errors.append(f"{where}.regressionRefs: 必须是列表")
|
||||
else:
|
||||
seen: set[str] = set()
|
||||
for index, ref in enumerate(refs):
|
||||
item_where = f"{where}.regressionRefs[{index}]"
|
||||
if not isinstance(ref, str) or REGRESSION_CASE_ID_RE.fullmatch(ref) is None:
|
||||
errors.append(f"{item_where}: 必须使用 REG-<id> 格式")
|
||||
continue
|
||||
if ref in seen:
|
||||
errors.append(f"{item_where}: 不能重复 {ref!r}")
|
||||
seen.add(ref)
|
||||
|
||||
if "regressionCandidates" not in task:
|
||||
return
|
||||
candidates = task["regressionCandidates"]
|
||||
if not isinstance(candidates, list):
|
||||
errors.append(f"{where}.regressionCandidates: 必须是列表")
|
||||
return
|
||||
for index, candidate in enumerate(candidates):
|
||||
item_where = f"{where}.regressionCandidates[{index}]"
|
||||
if not isinstance(candidate, dict):
|
||||
errors.append(f"{item_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
candidate, REGRESSION_CANDIDATE_FIELDS, item_where, errors
|
||||
)
|
||||
missing = sorted(REGRESSION_CANDIDATE_REQUIRED_FIELDS - set(candidate))
|
||||
for field in missing:
|
||||
errors.append(f"{item_where}.{field}: 必填")
|
||||
if "id" in candidate and (
|
||||
not isinstance(candidate.get("id"), str)
|
||||
or REGRESSION_CASE_ID_RE.fullmatch(candidate["id"]) is None
|
||||
):
|
||||
errors.append(f"{item_where}.id: 必须使用 REG-<id> 格式")
|
||||
if not _nonempty_string(candidate.get("title")):
|
||||
errors.append(f"{item_where}.title: 必须是非空字符串")
|
||||
if candidate.get("surface") not in REGRESSION_SURFACES:
|
||||
errors.append(f"{item_where}.surface: 必须是 {sorted(REGRESSION_SURFACES)}")
|
||||
if "suite" in candidate and candidate.get("suite") not in REGRESSION_SUITES:
|
||||
errors.append(f"{item_where}.suite: 必须是 {sorted(REGRESSION_SUITES)}")
|
||||
if "sourceKind" in candidate and (
|
||||
candidate.get("sourceKind") not in REGRESSION_SOURCE_KINDS
|
||||
):
|
||||
errors.append(
|
||||
f"{item_where}.sourceKind: 必须是 {sorted(REGRESSION_SOURCE_KINDS)}"
|
||||
)
|
||||
steps = candidate.get("steps")
|
||||
if (
|
||||
not isinstance(steps, list)
|
||||
or not steps
|
||||
or any(not _nonempty_string(step) for step in steps)
|
||||
):
|
||||
errors.append(f"{item_where}.steps: 必须是非空字符串列表")
|
||||
expected = candidate.get("expected")
|
||||
if not isinstance(expected, list) or not expected:
|
||||
errors.append(f"{item_where}.expected: 必须是非空列表")
|
||||
continue
|
||||
for expected_index, item in enumerate(expected):
|
||||
expected_where = f"{item_where}.expected[{expected_index}]"
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"{expected_where}: 必须是对象")
|
||||
continue
|
||||
if item.get("kind") not in REGRESSION_EXPECTED_KINDS:
|
||||
errors.append(
|
||||
f"{expected_where}.kind: 必须是 {sorted(REGRESSION_EXPECTED_KINDS)}"
|
||||
)
|
||||
if not _nonempty_string(item.get("value")):
|
||||
errors.append(f"{expected_where}.value: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_regression_runs(value: object, errors: list[str]) -> None:
|
||||
if not isinstance(value, list):
|
||||
errors.append("regressionRuns 必须是列表")
|
||||
return
|
||||
|
||||
seen_run_ids: set[str] = set()
|
||||
for index, run in enumerate(value):
|
||||
where = f"regressionRuns[{index}]"
|
||||
if not isinstance(run, dict):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
run,
|
||||
REGRESSION_RUN_FIELDS | REGRESSION_RUN_OPTIONAL_FIELDS,
|
||||
where,
|
||||
errors,
|
||||
)
|
||||
missing = sorted(REGRESSION_RUN_FIELDS - set(run))
|
||||
for field in missing:
|
||||
errors.append(f"{where}.{field}: 必填")
|
||||
|
||||
run_id = run.get("id")
|
||||
if not isinstance(run_id, str) or REGRESSION_RUN_ID_RE.fullmatch(run_id) is None:
|
||||
errors.append(f"{where}.id: 必须使用 RR-<id> 格式")
|
||||
elif run_id in seen_run_ids:
|
||||
errors.append(f"{where}.id: 不能重复 {run_id!r}")
|
||||
else:
|
||||
seen_run_ids.add(run_id)
|
||||
|
||||
if run.get("suite") not in REGRESSION_RUN_SUITES:
|
||||
errors.append(f"{where}.suite: 必须是 {sorted(REGRESSION_RUN_SUITES)}")
|
||||
status = run.get("status")
|
||||
if status not in REGRESSION_RUN_STATUSES:
|
||||
errors.append(f"{where}.status: 必须是 {sorted(REGRESSION_RUN_STATUSES)}")
|
||||
|
||||
case_ids = run.get("caseIds")
|
||||
if (
|
||||
not isinstance(case_ids, list)
|
||||
or not case_ids
|
||||
or any(
|
||||
not isinstance(case_id, str)
|
||||
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||
for case_id in case_ids
|
||||
)
|
||||
):
|
||||
errors.append(f"{where}.caseIds: 必须是非空 REG-<id> 列表")
|
||||
case_ids = []
|
||||
elif len(case_ids) != len(set(case_ids)):
|
||||
errors.append(f"{where}.caseIds: 不能包含重复值")
|
||||
|
||||
task_ids = run.get("taskIds")
|
||||
if task_ids is not None and (
|
||||
not isinstance(task_ids, list)
|
||||
or any(not _nonempty_string(task_id) for task_id in task_ids)
|
||||
):
|
||||
errors.append(f"{where}.taskIds: 必须是任务 ID 列表")
|
||||
delivery_run_id = run.get("deliveryRunId")
|
||||
if delivery_run_id is not None and (
|
||||
not isinstance(delivery_run_id, str)
|
||||
or DELIVERY_RUN_ID_RE.fullmatch(delivery_run_id) is None
|
||||
):
|
||||
errors.append(f"{where}.deliveryRunId: 必须使用 DR-<id> 格式")
|
||||
|
||||
for field in ("sourceRevision", "configRevision"):
|
||||
revision = run.get(field)
|
||||
if revision is not None and (
|
||||
not isinstance(revision, str) or GIT_REVISION_RE.fullmatch(revision) is None
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.{field}: 必须是 null 或 7..64 位小写十六进制 revision"
|
||||
)
|
||||
if status != "skipped":
|
||||
for field in ("sourceRevision", "configRevision", "baseUrl"):
|
||||
if not _nonempty_string(run.get(field)):
|
||||
errors.append(f"{where}.{field}: status={status!r} 时必须填写")
|
||||
|
||||
results = run.get("results")
|
||||
if not isinstance(results, list):
|
||||
errors.append(f"{where}.results: 必须是列表")
|
||||
results = []
|
||||
elif status in {"passed", "failed"} and not results:
|
||||
errors.append(f"{where}.results: status={status!r} 时不能为空")
|
||||
seen_results: set[str] = set()
|
||||
for result_index, result in enumerate(results):
|
||||
result_where = f"{where}.results[{result_index}]"
|
||||
if not isinstance(result, dict):
|
||||
errors.append(f"{result_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(result, REGRESSION_RESULT_FIELDS, result_where, errors)
|
||||
case_id = result.get("caseId")
|
||||
if (
|
||||
not isinstance(case_id, str)
|
||||
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||
):
|
||||
errors.append(f"{result_where}.caseId: 必须使用 REG-<id> 格式")
|
||||
elif case_id in seen_results:
|
||||
errors.append(f"{result_where}.caseId: 不能重复 {case_id!r}")
|
||||
else:
|
||||
seen_results.add(case_id)
|
||||
if case_id not in case_ids:
|
||||
errors.append(f"{result_where}.caseId: 不在 caseIds 中")
|
||||
if result.get("result") not in REGRESSION_RESULTS:
|
||||
errors.append(
|
||||
f"{result_where}.result: 必须是 {sorted(REGRESSION_RESULTS)}"
|
||||
)
|
||||
if not _nonempty_string(result.get("evidence")):
|
||||
errors.append(f"{result_where}.evidence: 必须是非空字符串")
|
||||
if status == "passed" and any(
|
||||
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||
):
|
||||
errors.append(f"{where}: passed 运行不能包含 fail 结果")
|
||||
if status == "failed" and not any(
|
||||
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||
):
|
||||
errors.append(f"{where}: failed 运行必须包含至少一条 fail 结果")
|
||||
|
||||
evidence = run.get("evidence")
|
||||
if not isinstance(evidence, list) or any(
|
||||
not _nonempty_string(item) for item in evidence
|
||||
):
|
||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||
if not _nonempty_string(run.get("updatedAt")):
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||
import jsonschema # type: ignore
|
||||
|
||||
@@ -779,6 +1025,19 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||
if "deliveryRuns" in data and "deliveryFile" not in project:
|
||||
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
||||
if (
|
||||
"regressionFile" in project
|
||||
and project.get("regressionFile") != ".pouch/ack/regression.yaml"
|
||||
):
|
||||
errors.append(
|
||||
"project.regressionFile 必须固定为 .pouch/ack/regression.yaml"
|
||||
)
|
||||
if "regressionFile" in project and not isinstance(
|
||||
data.get("regressionRuns"), list
|
||||
):
|
||||
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
|
||||
if "regressionRuns" in data and "regressionFile" not in project:
|
||||
errors.append("regressionRuns 存在时 project.regressionFile 必须存在")
|
||||
|
||||
ack_version = data.get("ackVersion")
|
||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||
@@ -941,6 +1200,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
||||
|
||||
validate_knowledge_fields(task, where, status, errors)
|
||||
validate_regression_fields(task, where, errors)
|
||||
|
||||
if "dispatch" not in task:
|
||||
dispatch = {}
|
||||
@@ -1129,6 +1389,8 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||
}
|
||||
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
||||
if "regressionRuns" in data:
|
||||
validate_regression_runs(data["regressionRuns"], errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
Reference in New Issue
Block a user