#!/usr/bin/env python3 """校验 tasks.yaml 是否符合 ack 任务板结构。 权威结构是同目录上层的 templates/tasks.schema.json(跨语言可用)。 本脚本是参考实现: - 始终执行内置语义校验; - 安装了 jsonschema 时,再叠加 schema 结构校验; - knowledge 字段会检查引用格式、候选结构和 Test 检查结果。 YAML 优先使用 PyYAML;未安装时使用 fail-closed 的 ACK YAML 子集。 JSON 任务板只使用标准库,两种格式都拒绝重复键。 用法: python3 validate_tasks.py [tasks.yaml] python3 validate_tasks.py --schema path/to/tasks.schema.json tasks.yaml 退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。 """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from approval_payload import approval_payload_hash from yaml_subset import ( DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader, ) from worker_profiles import validate_routing_document STATUS_ENUM = { "open", "dispatched", "fixed_by_dev", "retesting", "failed_retest", "verified", "blocked", "leftover", } MAX_ROUNDS = 3 KNOWLEDGE_KINDS = {"guardrail", "pitfall", "verification"} KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"} 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}$") 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( r"^(0|[1-9][0-9]*)\." r"(0|[1-9][0-9]*)\." r"(0|[1-9][0-9]*)" r"(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?" r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" ) KNOWLEDGE_SCOPE_FIELDS = { "components", "paths", "dependencies", "versions", "tags", "symbols", "errorSignatures", } KNOWLEDGE_APPLICATION_FIELDS = {"ref", "result", "evidence"} KNOWLEDGE_CANDIDATE_FIELDS = { "kind", "title", "claim", "scope", "appliesWhen", "directive", "rationale", "evidenceRefs", "proposedBy", "proposedAt", } KNOWLEDGE_CHECK_FIELDS = { "ref", "result", "evidence", "checkedBy", "checkedAt", } DELIVERY_RUN_FIELDS = { "id", "profile", "taskIds", "status", "sourceRevision", "configRevision", "pullRequest", "artifacts", "deployments", "evidence", "updatedAt", } DELIVERY_RUN_OPTIONAL_FIELDS = {"intent"} DELIVERY_RUN_INTENTS = {"testEnvironment", "release"} DELIVERY_STATUSES = { "planned", "running", "blocked", "failed", "validation_ready", "review_ready", "released", "skipped", } DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"} DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"} FEISHU_REQUIRED_FIELDS = { "title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt", } FEISHU_OPTIONAL_FIELDS = {"priority", "fixLogic"} FEISHU_CLARIFIED_FIELDS = { "title", "details", "problemStatement", "expectedOutcome", "acceptance", "intakeStatus", "ackTaskId", "attachments", "updatedAt", } FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"} FEISHU_SOURCE_FIELDS = { "kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision", "approvedPayloadHash", } FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"} FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") DISPATCH_FIELDS = { "taskId", "dispatchId", "worker", "developer", "test", "rounds", "environmentIncidents", } ENVIRONMENT_INCIDENT_FIELDS = { "id", "attemptId", "role", "phase", "status", "summary", "evidence", "impact", "recoveryAction", "userAction", "reportedAt", "resolvedAt", } ENVIRONMENT_INCIDENT_ROLES = {"coordinator", "developer", "test"} ENVIRONMENT_INCIDENT_PHASES = { "launch", "orchestration", "service", "test_data", "browser", "tooling", "permissions", "other", } ENVIRONMENT_INCIDENT_STATUSES = {"open", "resolved"} KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = { "kind", "title", "claim", "scope", "appliesWhen", "directive", "rationale", "evidenceRefs", } KNOWLEDGE_CANDIDATE_TEXT_FIELDS = { "title", "claim", "appliesWhen", "directive", "rationale", } def _nonempty_string(value: object) -> bool: return isinstance(value, str) and bool(value.strip()) def load_document(path: Path) -> dict: try: content = path.read_text(encoding="utf-8") except OSError as exc: sys.stderr.write(f"任务板读取失败: {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"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"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"YAML 解析失败: {exc}\n") raise SystemExit(1) if not isinstance(data, dict): sys.stderr.write("任务板顶层必须是对象(mapping)\n") raise SystemExit(1) return data def validate_knowledge_ref_list( value: object, where: str, errors: list[str], ) -> list[str]: if not isinstance(value, list): errors.append(f"{where}: 必须是列表") return [] refs: list[str] = [] seen: set[str] = set() for index, ref in enumerate(value): item_where = f"{where}[{index}]" if not isinstance(ref, str) or not KNOWLEDGE_REF_RE.fullmatch(ref): errors.append(f"{item_where}: 必须使用 K-@ 格式") continue if ref in seen: errors.append(f"{item_where}: 引用重复: {ref}") seen.add(ref) refs.append(ref) return refs def reject_unknown_fields( value: dict, allowed: set[str], where: str, errors: list[str], ) -> None: """Mirror additionalProperties=false for builtin knowledge validation.""" for field in sorted(set(value) - allowed): errors.append(f"{where}: 未知字段 {field!r}") def validate_optional_string_fields( value: dict, fields: set[str], where: str, errors: list[str], ) -> None: for field in sorted(fields): if field in value and not isinstance(value[field], str): errors.append(f"{where}.{field}: 必须是字符串") def validate_knowledge_fields( task: dict, where: str, status: object, errors: list[str], ) -> None: refs = ( validate_knowledge_ref_list( task["knowledgeRefs"], f"{where}.knowledgeRefs", errors, ) if "knowledgeRefs" in task else [] ) if "knowledgeApplied" in task: applications = task["knowledgeApplied"] if not isinstance(applications, list): errors.append(f"{where}.knowledgeApplied: 必须是列表") else: seen_applications: set[str] = set() for index, application in enumerate(applications): application_where = f"{where}.knowledgeApplied[{index}]" if not isinstance(application, dict): errors.append(f"{application_where}: 必须是对象") continue reject_unknown_fields( application, KNOWLEDGE_APPLICATION_FIELDS, application_where, errors, ) ref = application.get("ref") if not isinstance(ref, str) or not KNOWLEDGE_REF_RE.fullmatch(ref): errors.append( f"{application_where}.ref: 必须使用 K-@ 格式" ) else: if ref in seen_applications: errors.append(f"{application_where}.ref: 应用结果重复: {ref}") seen_applications.add(ref) if ref not in refs: errors.append(f"{application_where}.ref: {ref} 不在 knowledgeRefs 中") if application.get("result") not in {"applied", "not_applicable"}: errors.append( f"{application_where}.result: 必须是 applied/not_applicable" ) evidence = application.get("evidence") if not isinstance(evidence, str) or not evidence.strip(): errors.append(f"{application_where}.evidence: 必须提供非空证据") if "knowledgeCandidates" in task: candidates = task["knowledgeCandidates"] if not isinstance(candidates, list): errors.append(f"{where}.knowledgeCandidates: 必须是列表") else: for index, candidate in enumerate(candidates): candidate_where = f"{where}.knowledgeCandidates[{index}]" if not isinstance(candidate, dict): errors.append(f"{candidate_where}: 必须是对象") continue reject_unknown_fields( candidate, KNOWLEDGE_CANDIDATE_FIELDS, candidate_where, errors, ) missing = sorted( key for key in KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS if key not in candidate ) if missing: errors.append( f"{candidate_where}: 缺少必填字段 {', '.join(missing)}" ) if candidate.get("kind") not in KNOWLEDGE_KINDS: errors.append( f"{candidate_where}.kind: 必须是 {sorted(KNOWLEDGE_KINDS)}" ) for field in sorted(KNOWLEDGE_CANDIDATE_TEXT_FIELDS): value = candidate.get(field) if not isinstance(value, str) or not value.strip(): errors.append( f"{candidate_where}.{field}: 必须是非空字符串" ) validate_optional_string_fields( candidate, {"proposedBy", "proposedAt"}, candidate_where, errors, ) scope = candidate.get("scope") if not isinstance(scope, dict): errors.append(f"{candidate_where}.scope: 至少包含一个非空作用域") else: unknown_scope_fields = sorted( set(scope) - KNOWLEDGE_SCOPE_FIELDS - {"all"} ) for field in unknown_scope_fields: errors.append( f"{candidate_where}.scope: 未知字段 {field!r}" ) if "all" in scope and not isinstance(scope.get("all"), bool): errors.append(f"{candidate_where}.scope.all: 必须是布尔值") populated_scope_fields: list[str] = [] for field in sorted(KNOWLEDGE_SCOPE_FIELDS & set(scope)): values = scope.get(field) if not isinstance(values, list) or any( not isinstance(item, str) or not item.strip() for item in values ): errors.append( f"{candidate_where}.scope.{field}: " "必须是非空字符串列表" ) elif len(values) != len(set(values)): errors.append( f"{candidate_where}.scope.{field}: 不能包含重复值" ) elif values: populated_scope_fields.append(field) if scope.get("all") is True and populated_scope_fields: errors.append( f"{candidate_where}.scope: all=true 时不能同时填写作用域维度" ) if scope.get("all") is not True and not populated_scope_fields: errors.append( f"{candidate_where}.scope: 至少包含一个非空作用域" ) evidence_refs = candidate.get("evidenceRefs") if ( not isinstance(evidence_refs, list) or not evidence_refs or not all( isinstance(ref, str) and ref.strip() for ref in evidence_refs ) ): errors.append( f"{candidate_where}.evidenceRefs: 必须是非空字符串列表" ) elif len(evidence_refs) != len(set(evidence_refs)): errors.append( f"{candidate_where}.evidenceRefs: 不能包含重复值" ) if "knowledgeChecks" not in task: return checks = task["knowledgeChecks"] if not isinstance(checks, list): errors.append(f"{where}.knowledgeChecks: 必须是列表") return seen_checks: set[str] = set() for index, check in enumerate(checks): check_where = f"{where}.knowledgeChecks[{index}]" if not isinstance(check, dict): errors.append(f"{check_where}: 必须是对象") continue reject_unknown_fields( check, KNOWLEDGE_CHECK_FIELDS, check_where, errors, ) validate_optional_string_fields( check, {"checkedBy", "checkedAt"}, check_where, errors, ) ref = check.get("ref") result = check.get("result") evidence = check.get("evidence") if not isinstance(ref, str) or not KNOWLEDGE_REF_RE.fullmatch(ref): errors.append(f"{check_where}.ref: 必须使用 K-@ 格式") else: if ref in seen_checks: errors.append(f"{check_where}.ref: 检查结果重复: {ref}") seen_checks.add(ref) if ref not in refs: errors.append(f"{check_where}.ref: {ref} 不在 knowledgeRefs 中") if result not in KNOWLEDGE_CHECK_RESULTS: errors.append( f"{check_where}.result: 必须是 {sorted(KNOWLEDGE_CHECK_RESULTS)}" ) if not isinstance(evidence, str) or not evidence.strip(): errors.append(f"{check_where}.evidence: 必须提供非空证据") if status == "verified" and result == "failed": errors.append(f"{check_where}: verified 任务不能保留失败的知识检查") def validate_delivery_runs( value: object, task_statuses: dict[str, object], errors: list[str], ) -> None: if not isinstance(value, list): errors.append("deliveryRuns 必须是列表") return seen_run_ids: set[str] = set() for index, run in enumerate(value): where = f"deliveryRuns[{index}]" if not isinstance(run, dict): errors.append(f"{where}: 必须是对象") continue reject_unknown_fields( run, DELIVERY_RUN_FIELDS | DELIVERY_RUN_OPTIONAL_FIELDS, where, errors ) missing = sorted(DELIVERY_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 DELIVERY_RUN_ID_RE.fullmatch(run_id) is None: errors.append(f"{where}.id: 必须使用 DR- 格式") elif run_id in seen_run_ids: errors.append(f"{where}.id: 不能重复 {run_id!r}") else: seen_run_ids.add(run_id) profile = run.get("profile") if not isinstance(profile, str) or DELIVERY_PROFILE_RE.fullmatch(profile) is None: errors.append(f"{where}.profile: 必须使用小写连字符 ID") status = run.get("status") if status not in DELIVERY_STATUSES: errors.append(f"{where}.status: 必须是 {sorted(DELIVERY_STATUSES)}") intent = run.get("intent") if "intent" in run and intent not in DELIVERY_RUN_INTENTS: errors.append( f"{where}.intent: 必须是 {sorted(DELIVERY_RUN_INTENTS)}" ) task_ids = run.get("taskIds") allow_empty_tasks = intent in DELIVERY_RUN_INTENTS if ( not isinstance(task_ids, list) or (not task_ids and not allow_empty_tasks) or any(not _nonempty_string(task_id) for task_id in (task_ids or [])) ): errors.append( f"{where}.taskIds: 必须是任务 ID 列表" if allow_empty_tasks else f"{where}.taskIds: 必须是非空任务 ID 列表" ) task_ids = [] elif len(task_ids) != len(set(task_ids)): errors.append(f"{where}.taskIds: 不能包含重复值") for task_id in task_ids: if task_id not in task_statuses: errors.append(f"{where}.taskIds: 未知任务 {task_id!r}") elif task_statuses[task_id] != "verified": errors.append( f"{where}: delivery run 只能引用 verified 任务," f"{task_id!r} 当前是 {task_statuses[task_id]!r}" ) 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") pull_request = run.get("pullRequest") if pull_request is not None and not isinstance(pull_request, str): errors.append(f"{where}.pullRequest: 必须是字符串或 null") if status != "skipped": for field in ("sourceRevision", "configRevision"): if not _nonempty_string(run.get(field)): errors.append(f"{where}.{field}: status={status!r} 时必须填写") if status in {"review_ready", "released"}: if not _nonempty_string(run.get("pullRequest")): errors.append(f"{where}.pullRequest: status={status!r} 时必须填写") artifacts = run.get("artifacts") if not isinstance(artifacts, list): errors.append(f"{where}.artifacts: 必须是列表") else: seen_artifacts: set[str] = set() for artifact_index, artifact in enumerate(artifacts): artifact_where = f"{where}.artifacts[{artifact_index}]" if not isinstance(artifact, dict): errors.append(f"{artifact_where}: 必须是对象") continue reject_unknown_fields( artifact, DELIVERY_ARTIFACT_FIELDS, artifact_where, errors, ) artifact_id = artifact.get("id") if ( not isinstance(artifact_id, str) or DELIVERY_PROFILE_RE.fullmatch(artifact_id) is None ): errors.append(f"{artifact_where}.id: 必须使用小写连字符 ID") elif artifact_id in seen_artifacts: errors.append(f"{artifact_where}.id: 不能重复 {artifact_id!r}") else: seen_artifacts.add(artifact_id) if artifact.get("type") not in {"deb", "oci-image", "file"}: errors.append(f"{artifact_where}.type: 必须是 deb/oci-image/file") if not _nonempty_string(artifact.get("reference")): errors.append(f"{artifact_where}.reference: 必须是非空字符串") digest = artifact.get("digest") if digest is not None and ( not isinstance(digest, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None ): errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null") if status in {"validation_ready", "review_ready", "released"} and not _nonempty_string(digest): errors.append( f"{artifact_where}.digest: status={status!r} 时必须填写" ) deployments = run.get("deployments") if not isinstance(deployments, list): errors.append(f"{where}.deployments: 必须是列表") else: seen_environments: set[str] = set() for deployment_index, deployment in enumerate(deployments): deployment_where = f"{where}.deployments[{deployment_index}]" if not isinstance(deployment, dict): errors.append(f"{deployment_where}: 必须是对象") continue reject_unknown_fields( deployment, DELIVERY_DEPLOYMENT_FIELDS, deployment_where, errors, ) environment = deployment.get("environment") if ( not isinstance(environment, str) or DELIVERY_PROFILE_RE.fullmatch(environment) is None ): errors.append(f"{deployment_where}.environment: 必须使用小写连字符 ID") elif environment in seen_environments: errors.append(f"{deployment_where}.environment: 不能重复 {environment!r}") else: seen_environments.add(environment) if deployment.get("result") not in {"succeeded", "failed", "rolled_back"}: errors.append( f"{deployment_where}.result: 必须是 succeeded/failed/rolled_back" ) if not _nonempty_string(deployment.get("evidence")): errors.append(f"{deployment_where}.evidence: 必须是非空字符串") evidence = run.get("evidence") if not isinstance(evidence, list) or any( not _nonempty_string(item) for item in evidence ): errors.append(f"{where}.evidence: 必须是字符串列表") elif status in {"blocked", "failed", "validation_ready", "review_ready", "released", "skipped"} and not evidence: errors.append(f"{where}.evidence: status={status!r} 时不能为空") 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 schema = json.loads(schema_path.read_text(encoding="utf-8")) validator = jsonschema.Draft7Validator(schema) errors = [] for err in sorted(validator.iter_errors(data), key=lambda e: list(e.path)): loc = "/".join(str(p) for p in err.path) or "" errors.append(f"[schema] {loc}: {err.message}") return errors def validate_builtin(data: dict) -> list[str]: errors: list[str] = [] project_intake_workflow = "read-only-v1" def validate_string_fields( value: dict, fields: set[str], where: str, *, nullable: bool = False, ) -> None: expected = "必须是字符串或 null" if nullable else "必须是字符串" for field in sorted(fields): if field not in value: continue field_value = value[field] if not isinstance(field_value, str) and not ( nullable and field_value is None ): errors.append(f"{where}.{field}: {expected}") def validate_string_lists( value: dict, fields: set[str], where: str, ) -> None: for field in sorted(fields): if field not in value: continue items = value[field] if not isinstance(items, list): errors.append(f"{where}.{field}: 必须是列表") elif any(not isinstance(item, str) for item in items): errors.append(f"{where}.{field}: 列表项必须是字符串") def validate_object_fields( value: dict, fields: set[str], where: str, ) -> None: for field in sorted(fields): if field in value and not isinstance(value[field], dict): errors.append(f"{where}.{field}: 必须是对象") version = data.get("version") if ( not isinstance(version, int) or isinstance(version, bool) or version < 1 ): errors.append("version 必须是 >=1 的整数") validate_string_fields( data, {"updatedAt", "source", "ackVersion", "kitVersion"}, "", ) project = data.get("project") if not isinstance(project, dict): errors.append("project 必须是对象") else: if not _nonempty_string(project.get("name")): errors.append("project.name 必须是非空字符串") validate_string_fields( project, {"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"}, "project", ) if "bugIntake" in project: intake = project["bugIntake"] if not isinstance(intake, dict): errors.append("project.bugIntake 必须是对象") else: reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors) if intake.get("provider") != "feishu-base": errors.append("project.bugIntake.provider 必须是 feishu-base") workflow = intake.get("workflow", "read-only-v1") if workflow in FEISHU_WORKFLOWS: project_intake_workflow = workflow if workflow not in FEISHU_WORKFLOWS: errors.append("project.bugIntake.workflow 非法") profile = intake.get("profile") if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None: errors.append("project.bugIntake.profile 非法") for key in ("baseToken", "tableId", "viewId"): value = intake.get(key) if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value): errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串") fields = intake.get("fields") expected_fields = ( FEISHU_CLARIFIED_FIELDS if workflow == "clarified-writeback-v1" else FEISHU_REQUIRED_FIELDS ) allowed_fields = expected_fields | ( set() if workflow == "clarified-writeback-v1" else FEISHU_OPTIONAL_FIELDS ) if ( not isinstance(fields, dict) or not expected_fields.issubset(fields) or not set(fields).issubset(allowed_fields) ): errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段") elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()): errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串") elif len(set(fields.values())) != len(fields): errors.append("project.bugIntake.fields 字段值不能重复") elif workflow == "reviewed-writeback-v1" and not { "fixLogic", "priority" }.issubset(fields): errors.append( "reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic 和 priority" ) if ( "knowledgeFile" in project and project.get("knowledgeFile") != "docs/ack/knowledge.yaml" ): errors.append( "project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml" ) if ( "deliveryFile" in project and project.get("deliveryFile") != "docs/ack/delivery.yaml" ): errors.append( "project.deliveryFile 必须固定为 docs/ack/delivery.yaml" ) if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list): errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表") if "deliveryRuns" in data and "deliveryFile" not in project: errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在") ack_version = data.get("ackVersion") version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None if "ackVersion" in data and version_match is None: errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)") routing_required = ( (isinstance(project, dict) and "orchestration" in project) or "workerReceipts" in data or ( version_match is not None and (int(version_match.group(1)), int(version_match.group(2))) >= (0, 10) ) ) if routing_required: errors.extend(validate_routing_document(data)) if "summary" in data: summary = data["summary"] if not isinstance(summary, dict): errors.append("summary 必须是对象") else: validate_string_lists( summary, {"verified", "open", "failedRetest", "leftovers"}, "summary", ) if "statusReference" in data and not isinstance( data["statusReference"], dict ): errors.append("statusReference 必须是对象") tasks = data.get("tasks") if not isinstance(tasks, list): errors.append("tasks 必须是列表") return errors seen_ids: set[str] = set() seen_source_refs: set[str] = set() for i, task in enumerate(tasks): where = f"tasks[{i}]" if not isinstance(task, dict): errors.append(f"{where}: 必须是对象") continue tid = task.get("id") title = task.get("title") status = task.get("status") if not _nonempty_string(tid): errors.append(f"{where}: id 必须是非空字符串") else: where = f"tasks[{i}] {tid}" if routing_required and TASK_ID_RE.fullmatch(tid) is None: errors.append( f"{where}: v0.10 自动路由 id 只允许字母、数字、点、下划线和连字符" ) if tid in seen_ids: errors.append(f"{where}: id 重复") seen_ids.add(tid) if not _nonempty_string(title): errors.append(f"{where}: title 必须是非空字符串") if status not in STATUS_ENUM: errors.append( f"{where}: status={status!r} 非法,应为 {sorted(STATUS_ENUM)}" ) validate_string_fields( task, { "type", "priority", "assignee", "component", "description", "fixLogic", "expected", "actual", }, where, ) validate_string_lists( task, {"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"}, where, ) validate_object_fields(task, {"evidence", "verification"}, where) if "source" in task: source = task["source"] # `source` was historically an open extension point. Preserve # non-Feishu strings/objects and tighten only the namespaced shape. if isinstance(source, dict) and source.get("kind") == "feishu-base": reject_unknown_fields(source, FEISHU_SOURCE_FIELDS, f"{where}.source", errors) ref = source.get("ref") if not isinstance(ref, str) or FEISHU_SOURCE_REF_RE.fullmatch(ref) is None: errors.append(f"{where}.source.ref: 必须是不透明 feishu-base SHA-256 引用") else: if ref in seen_source_refs: errors.append(f"{where}.source.ref: 来源引用重复") seen_source_refs.add(ref) record_id = source.get("recordId") if not isinstance(record_id, str) or FEISHU_RECORD_ID_RE.fullmatch(record_id) is None: errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID") if not _nonempty_string(source.get("updatedAt")): errors.append(f"{where}.source.updatedAt: 必须是非空字符串") source_workflow = source.get("workflow", "read-only-v1") if source_workflow not in FEISHU_WORKFLOWS: errors.append(f"{where}.source.workflow: 非法") if ( project_intake_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"} and source_workflow != project_intake_workflow and status not in {"verified", "leftover"} ): errors.append( f"{where}.source.workflow: reviewed 项目的可执行飞书任务必须先迁移审核" ) approved_revision = source.get("approvedRevision") stored_payload_hash = source.get("approvedPayloadHash") is_approved_workflow = source_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"} if is_approved_workflow and approved_revision is None: errors.append(f"{where}.source.approvedRevision: writeback workflow 必填") elif approved_revision is not None and ( not isinstance(approved_revision, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None ): errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision") if is_approved_workflow: if ( not isinstance(stored_payload_hash, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None ): errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填") required_strings = ( ("title", "description", "actual", "expected") if source_workflow == "clarified-writeback-v1" else ("title", "priority", "actual", "expected", "fixLogic") ) for field in required_strings: if not _nonempty_string(task.get(field)): errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串") required_lists = ( ("acceptanceCriteria",) if source_workflow == "clarified-writeback-v1" else ("stepsToReproduce", "acceptanceCriteria") ) for field in required_lists: items = task.get(field) if ( not isinstance(items, list) or not items or any(not _nonempty_string(item) for item in items) ): errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串列表") if ( isinstance(stored_payload_hash, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) and stored_payload_hash != approval_payload_hash(task) ): errors.append(f"{where}.source.approvedPayloadHash: 与任务审核字段不匹配") elif stored_payload_hash is not None: errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow") validate_knowledge_fields(task, where, status, errors) if "dispatch" not in task: dispatch = {} elif not isinstance(task["dispatch"], dict): errors.append(f"{where}.dispatch: 必须是对象") dispatch = {} else: dispatch = task["dispatch"] reject_unknown_fields( dispatch, DISPATCH_FIELDS, f"{where}.dispatch", errors, ) validate_string_fields( dispatch, {"taskId", "dispatchId", "worker"}, f"{where}.dispatch", nullable=True, ) rounds = dispatch.get("rounds", []) if not isinstance(rounds, list): errors.append(f"{where}.dispatch.rounds: 必须是列表") else: if len(rounds) > MAX_ROUNDS: errors.append( f"{where}: 派发轮次 {len(rounds)} 超过上限 {MAX_ROUNDS}" ) seen_attempt_ids: set[str] = set() round_numbers: list[int] = [] for round_index, round_item in enumerate(rounds): round_where = f"{where}.dispatch.rounds[{round_index}]" if not isinstance(round_item, dict): errors.append(f"{round_where}: 必须是对象") continue if round_item.get("result") not in {"passed", "failed"}: errors.append(f"{round_where}.result: 必须是 passed/failed") if "evidence" in round_item and not isinstance( round_item["evidence"], str ): errors.append(f"{round_where}.evidence: 必须是字符串") round_number = round_item.get("round") round_number_is_valid = ( isinstance(round_number, int) and not isinstance(round_number, bool) and 1 <= round_number <= MAX_ROUNDS ) if not round_number_is_valid: errors.append( f"{round_where}.round: 必须是 1..{MAX_ROUNDS} 的整数" ) else: round_numbers.append(round_number) if "attemptId" in round_item: attempt_id = round_item["attemptId"] if ( not isinstance(attempt_id, str) or not ATTEMPT_ID_RE.fullmatch(attempt_id) ): errors.append( f"{round_where}.attemptId: " "必须使用 -A 格式" ) else: if attempt_id in seen_attempt_ids: errors.append( f"{round_where}.attemptId: " f"轮次内不能重复: {attempt_id}" ) seen_attempt_ids.add(attempt_id) if ( isinstance(tid, str) and round_number_is_valid and attempt_id != f"{tid}-A{round_number}" ): errors.append( f"{round_where}.attemptId: 应为 " f"{tid}-A{round_number}" ) expected_rounds = list(range(1, len(rounds) + 1)) if round_numbers != expected_rounds: errors.append( f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复" ) incidents = dispatch.get("environmentIncidents", []) if not isinstance(incidents, list): errors.append(f"{where}.dispatch.environmentIncidents: 必须是列表") else: seen_incident_ids: set[str] = set() for incident_index, incident in enumerate(incidents): incident_where = ( f"{where}.dispatch.environmentIncidents[{incident_index}]" ) if not isinstance(incident, dict): errors.append(f"{incident_where}: 必须是对象") continue reject_unknown_fields( incident, ENVIRONMENT_INCIDENT_FIELDS, incident_where, errors, ) incident_id = incident.get("id") expected_id = ( f"{tid}-ENV-{incident_index + 1}" if isinstance(tid, str) else None ) if not isinstance(incident_id, str) or incident_id != expected_id: errors.append(f"{incident_where}.id: 应为 {expected_id}") elif incident_id in seen_incident_ids: errors.append(f"{incident_where}.id: 不能重复 {incident_id}") else: seen_incident_ids.add(incident_id) if incident.get("role") not in ENVIRONMENT_INCIDENT_ROLES: errors.append( f"{incident_where}.role: 必须是 coordinator/developer/test" ) if incident.get("phase") not in ENVIRONMENT_INCIDENT_PHASES: errors.append(f"{incident_where}.phase: 非法环境阶段") incident_status = incident.get("status") if incident_status not in ENVIRONMENT_INCIDENT_STATUSES: errors.append(f"{incident_where}.status: 必须是 open/resolved") for field in ( "summary", "evidence", "impact", "recoveryAction", "userAction", "reportedAt", ): if not _nonempty_string(incident.get(field)): errors.append(f"{incident_where}.{field}: 必须是非空字符串") if "attemptId" in incident and not ( incident["attemptId"] is None or _nonempty_string(incident["attemptId"]) ): errors.append(f"{incident_where}.attemptId: 必须是字符串或 null") if "resolvedAt" in incident and not ( incident["resolvedAt"] is None or _nonempty_string(incident["resolvedAt"]) ): errors.append(f"{incident_where}.resolvedAt: 必须是字符串或 null") if incident_status == "resolved" and not _nonempty_string( incident.get("resolvedAt") ): errors.append( f"{incident_where}: resolved 必须填写 resolvedAt" ) resolution = task.get("resolution") if "resolution" in task: if not isinstance(resolution, dict): errors.append(f"{where}.resolution: 必须是对象") else: validate_string_fields( resolution, { "fixedBy", "verifiedBy", "verifiedAt", "leftoverReason", }, f"{where}.resolution", nullable=True, ) validate_object_fields( resolution, {"evidence"}, f"{where}.resolution", ) if status == "leftover": if ( not isinstance(resolution, dict) or not _nonempty_string(resolution.get("leftoverReason")) ): errors.append(f"{where}: leftover 必须填 resolution.leftoverReason") if "deliveryRuns" in data: task_statuses = { task.get("id"): task.get("status") for task in tasks if isinstance(task, dict) and _nonempty_string(task.get("id")) } validate_delivery_runs(data["deliveryRuns"], task_statuses, errors) return errors def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="校验 tasks.yaml 结构") parser.add_argument("tasks", nargs="?", default="tasks.yaml", help="任务板路径") parser.add_argument("--schema", help="tasks.schema.json 路径(默认自动探测)") args = parser.parse_args(argv) tasks_path = Path(args.tasks) if not tasks_path.is_file(): sys.stderr.write(f"找不到任务板文件: {tasks_path}\n") return 2 data = load_document(tasks_path) schema_path = Path(args.schema) if args.schema else ( Path(__file__).resolve().parent.parent / "templates" / "tasks.schema.json" ) if args.schema and not schema_path.is_file(): sys.stderr.write(f"找不到指定的 schema 文件: {schema_path}\n") return 2 errors = validate_builtin(data) mode = "内置语义规则" try: import jsonschema # type: ignore # noqa: F401 if schema_path.is_file(): errors = validate_with_schema(data, schema_path) + errors mode = f"schema ({schema_path.name}) + 内置语义规则" else: mode = "内置语义规则(未找到 schema 文件)" except ImportError: pass if errors: sys.stderr.write(f"任务板校验失败({mode}),共 {len(errors)} 项:\n") for e in errors: sys.stderr.write(f" - {e}\n") return 1 sys.stdout.write(f"任务板校验通过({mode}):{tasks_path}\n") return 0 if __name__ == "__main__": raise SystemExit(main())