Files
.pouch/skills/ack/scripts/validate_knowledge.py
T
laily f3cd56b78e feat: rename skills/skiff to pouch and move ACK state under .pouch
Use ~/.pouch, the pouch CLI, and .pouch.yaml as the SSOT container.
Keep the inner skills/ packages, and store ACK project state in
.pouch/ack instead of docs/ack.
2026-08-25 15:20:02 +08:00

1157 lines
44 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""校验 ACK 项目知识护栏库及其任务引用。
权威结构位于 templates/knowledge.schema.json。jsonschema 是可选依赖;无论是否
安装,引用、作用域、复核、时效和冲突等语义规则都会执行。本脚本只解析数据,
不会执行 directive、verification 或其它自由文本中的命令。
用法:
python3 validate_knowledge.py [knowledge.yaml]
python3 validate_knowledge.py knowledge.yaml --tasks tasks.yaml
python3 validate_knowledge.py --schema path/to/knowledge.schema.json knowledge.yaml
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
"""
from __future__ import annotations
import argparse
import json
import re
import stat
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
KINDS = {"guardrail", "pitfall", "verification"}
STATUSES = {"active", "stale", "superseded", "archived"}
CHECK_RESULTS = {"passed", "failed", "not_applicable"}
APPLIED_RESULTS = {"applied", "not_applicable"}
TERMINAL_TASK_STATUSES = {"verified", "leftover"}
MAX_ROUNDS = 3
ID_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*$")
REF_RE = re.compile(r"^(K-[A-Z0-9][A-Z0-9-]*)@([1-9][0-9]*)$")
SUBJECT_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
VERIFICATION_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
SAFE_RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
SCOPE_FIELDS = (
"components",
"paths",
"dependencies",
"versions",
"tags",
"symbols",
"errorSignatures",
)
FORBIDDEN_EXECUTION_KEYS = {
"argv",
"command",
"commands",
"executable",
"script",
"shell",
}
SECRET_PATTERNS = (
(
"private key",
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"),
),
(
"GitHub token",
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
),
(
"OpenAI-style token",
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
),
(
"AWS access key",
re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
),
(
"URL credentials",
re.compile(r"https?://[^/\s:@]+:[^/\s@]+@"),
),
(
"inline secret assignment",
re.compile(
r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)"
r"\s*[:=]\s*[\"']?[^\s,\"']{8,}"
),
),
)
ENTRY_REQUIRED = {
"id",
"revision",
"kind",
"status",
"title",
"subject",
"scope",
"appliesWhen",
"directive",
"rationale",
"verification",
"provenance",
"owner",
"author",
"reviewer",
"approval",
"createdAt",
"lastValidatedAt",
"reviewAfter",
"temporary",
"removalCondition",
"statusReason",
"supersedes",
"conflictsWith",
}
def load_yaml_text(content: str, label: str = "YAML") -> dict[str, Any]:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"{label} 子集解析失败: {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} 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write(f"{label} 顶层必须是对象(mapping\n")
raise SystemExit(1)
return data
def load_yaml(path: Path, label: str = "YAML") -> 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)
if not isinstance(data, dict):
sys.stderr.write(f"{label} 顶层必须是对象(mapping\n")
raise SystemExit(1)
return data
return load_yaml_text(content, label)
def stable_ref(entry: dict[str, Any]) -> str | None:
entry_id = entry.get("id")
revision = entry.get("revision")
if (
isinstance(entry_id, str)
and ID_RE.fullmatch(entry_id)
and isinstance(revision, int)
and not isinstance(revision, bool)
and revision >= 1
):
return f"{entry_id}@{revision}"
return None
def _nonempty(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _timestamp(value: Any) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else None
def infer_project_root(document_path: Path) -> Path | None:
"""从标准 .pouch/ack 布局或 Git marker 推断项目根,不解析文档 symlink。"""
lexical = document_path.expanduser().absolute()
parent = lexical.parent
if parent.name == "ack" and parent.parent.name in {".pouch", "docs"}:
return parent.parent.parent.resolve()
for candidate in (parent, *parent.parents):
if (candidate / ".git").exists():
return candidate.resolve()
return None
def _is_within(candidate: Path, root: Path) -> bool:
try:
candidate.relative_to(root)
except ValueError:
return False
return True
def _validate_registry_target(
project_root: Path,
relative_path: str,
where: str,
) -> list[str]:
"""Mirror runner path policy without executing or following symlinks."""
errors: list[str] = []
current = project_root
parts = relative_path.split("/")
for index, segment in enumerate(parts):
current = current / segment
try:
metadata = current.lstat()
except FileNotFoundError:
return [f"{where}: 检查目标不存在: {relative_path!r}"]
except OSError as exc:
return [f"{where}: 检查目标不可访问: {relative_path!r}: {exc}"]
if stat.S_ISLNK(metadata.st_mode):
return [f"{where}: 检查目标路径不能包含 symlink: {relative_path!r}"]
if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode):
return [f"{where}: 中间路径不是目录: {relative_path!r}"]
if not stat.S_ISREG(metadata.st_mode):
errors.append(f"{where}: 检查目标必须是普通文件: {relative_path!r}")
elif metadata.st_mode & 0o111 == 0:
errors.append(f"{where}: 检查目标不可执行: {relative_path!r}")
return errors
def _tasks_project_root(
tasks_data: dict[str, Any], tasks_path: Path | None
) -> Path | None:
inferred = infer_project_root(tasks_path) if tasks_path else None
if inferred is not None and inferred.is_dir():
return inferred
# Legacy task boards may still declare repoPath. It is only a fallback for
# non-standard layouts; .pouch/ack location is authoritative when available.
project = tasks_data.get("project")
repo_path = project.get("repoPath") if isinstance(project, dict) else None
if _nonempty(repo_path):
candidate = Path(repo_path).expanduser()
if not candidate.is_absolute():
if inferred is None:
return None
candidate = inferred / candidate
if not candidate.is_dir():
return None
return candidate.resolve(strict=True)
return None
def _unknown_keys(value: dict[str, Any], allowed: set[str], where: str) -> list[str]:
return [f"{where}: 未知字段 {key!r}" for key in sorted(set(value) - allowed)]
def validate_builtin_structure(data: dict[str, Any]) -> list[str]:
"""jsonschema 不可用时覆盖关键结构规则。"""
errors: list[str] = []
errors.extend(
_unknown_keys(
data,
{"version", "updatedAt", "project", "verificationRegistry", "entries"},
"<root>",
)
)
version = data.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
errors.append("version 必须是 >=1 的整数")
if _timestamp(data.get("updatedAt")) is None:
errors.append("updatedAt 必须是带时区的 ISO 8601 时间")
project = data.get("project")
if not isinstance(project, dict):
errors.append("project 必须是对象")
else:
errors.extend(_unknown_keys(project, {"name"}, "project"))
if not _nonempty(project.get("name")):
errors.append("project.name 必填")
registry = data.get("verificationRegistry")
if not isinstance(registry, dict):
errors.append("verificationRegistry 必须是对象")
else:
for ref, target in registry.items():
where = f"verificationRegistry.{ref}"
if not isinstance(ref, str) or not VERIFICATION_REF_RE.fullmatch(ref):
errors.append(f"{where}: 检查 ID 格式非法")
if not isinstance(target, dict):
errors.append(f"{where}: 必须是对象")
continue
errors.extend(_unknown_keys(target, {"path", "args"}, where))
if not _nonempty(target.get("path")):
errors.append(f"{where}.path: 必须是非空字符串")
args = target.get("args")
if not isinstance(args, list) or any(
not isinstance(arg, str) for arg in args
):
errors.append(f"{where}.args: 必须是结构化字符串数组")
entries = data.get("entries")
if not isinstance(entries, list):
errors.append("entries 必须是列表")
return errors
for index, entry in enumerate(entries):
where = f"entries[{index}]"
if not isinstance(entry, dict):
errors.append(f"{where}: 必须是对象")
continue
errors.extend(_unknown_keys(entry, ENTRY_REQUIRED, where))
for field in sorted(ENTRY_REQUIRED - set(entry)):
errors.append(f"{where}: {field} 必填")
entry_id = entry.get("id")
if not isinstance(entry_id, str) or not ID_RE.fullmatch(entry_id):
errors.append(f"{where}.id: 必须匹配 K-[A-Z0-9-]+")
revision = entry.get("revision")
if (
not isinstance(revision, int)
or isinstance(revision, bool)
or revision < 1
):
errors.append(f"{where}.revision: 必须是 >=1 的整数")
if entry.get("kind") not in KINDS:
errors.append(f"{where}.kind: 应为 {sorted(KINDS)}")
if entry.get("status") not in STATUSES:
errors.append(f"{where}.status: 应为 {sorted(STATUSES)}")
if not isinstance(entry.get("temporary"), bool):
errors.append(f"{where}.temporary: 必须是布尔值")
for field in (
"title",
"appliesWhen",
"directive",
"rationale",
"owner",
"author",
"reviewer",
):
if not _nonempty(entry.get(field)):
errors.append(f"{where}.{field}: 必须是非空字符串")
subject = entry.get("subject")
if not isinstance(subject, str) or not SUBJECT_RE.fullmatch(subject):
errors.append(f"{where}.subject: 必须是小写连字符标识")
scope = entry.get("scope")
if not isinstance(scope, dict):
errors.append(f"{where}.scope: 必须是对象")
else:
allowed = {"all", *SCOPE_FIELDS}
errors.extend(_unknown_keys(scope, allowed, f"{where}.scope"))
if not isinstance(scope.get("all"), bool):
errors.append(f"{where}.scope.all: 必须是布尔值")
for field in SCOPE_FIELDS:
values = scope.get(field)
if not isinstance(values, list):
errors.append(f"{where}.scope.{field}: 必须是列表")
elif (
any(not _nonempty(item) for item in values)
or len(values) != len(set(values))
):
errors.append(
f"{where}.scope.{field}: 必须是非空且不重复的字符串列表"
)
verification = entry.get("verification")
if not isinstance(verification, dict):
errors.append(f"{where}.verification: 必须是对象")
else:
errors.extend(
_unknown_keys(
verification, {"ref", "expected"}, f"{where}.verification"
)
)
verification_ref = verification.get("ref")
if (
not isinstance(verification_ref, str)
or not VERIFICATION_REF_RE.fullmatch(verification_ref)
):
errors.append(
f"{where}.verification.ref: 必须是检查 ID,不能是命令"
)
if not _nonempty(verification.get("expected")):
errors.append(f"{where}.verification.expected: 必填")
provenance = entry.get("provenance")
provenance_fields = {"taskId", "attemptId", "codeRef", "evidenceRef"}
if not isinstance(provenance, dict):
errors.append(f"{where}.provenance: 必须是对象")
else:
errors.extend(
_unknown_keys(provenance, provenance_fields, f"{where}.provenance")
)
for field in sorted(provenance_fields):
if not _nonempty(provenance.get(field)):
errors.append(f"{where}.provenance.{field}: 必填")
approval = entry.get("approval")
approval_fields = {"approvedBy", "approvedAt", "evidenceRef"}
if approval is not None:
if not isinstance(approval, dict):
errors.append(f"{where}.approval: 必须为 null 或对象")
else:
errors.extend(
_unknown_keys(approval, approval_fields, f"{where}.approval")
)
for field in ("approvedBy", "evidenceRef"):
if not _nonempty(approval.get(field)):
errors.append(f"{where}.approval.{field}: 必填")
if _timestamp(approval.get("approvedAt")) is None:
errors.append(
f"{where}.approval.approvedAt: 必须是带时区的 ISO 8601 时间"
)
for field in ("createdAt", "lastValidatedAt"):
if _timestamp(entry.get(field)) is None:
errors.append(f"{where}.{field}: 必须是带时区的 ISO 8601 时间")
if entry.get("reviewAfter") is not None and _timestamp(
entry.get("reviewAfter")
) is None:
errors.append(
f"{where}.reviewAfter: 必须为 null 或带时区的 ISO 8601 时间"
)
for field in ("removalCondition", "statusReason"):
if entry.get(field) is not None and not _nonempty(entry.get(field)):
errors.append(f"{where}.{field}: 必须为 null 或非空字符串")
for field in ("supersedes", "conflictsWith"):
refs = entry.get(field)
if not isinstance(refs, list):
errors.append(f"{where}.{field}: 必须是列表")
elif (
any(not isinstance(ref, str) or not REF_RE.fullmatch(ref) for ref in refs)
or len(refs) != len(set(refs))
):
errors.append(
f"{where}.{field}: 必须是不重复的 K-...@revision 引用列表"
)
return errors
def _find_forbidden_keys(value: Any, path: str = "<root>") -> list[str]:
errors: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}.{key}"
if str(key).casefold() in FORBIDDEN_EXECUTION_KEYS:
errors.append(
f"{child_path}: knowledge.yaml 不保存或执行自由命令;请使用 verification.ref"
)
errors.extend(_find_forbidden_keys(child, child_path))
elif isinstance(value, list):
for index, child in enumerate(value):
errors.extend(_find_forbidden_keys(child, f"{path}[{index}]"))
return errors
def _find_secrets(value: Any, path: str = "<root>") -> list[str]:
errors: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
errors.extend(_find_secrets(child, f"{path}.{key}"))
elif isinstance(value, list):
for index, child in enumerate(value):
errors.extend(_find_secrets(child, f"{path}[{index}]"))
elif isinstance(value, str):
for label, pattern in SECRET_PATTERNS:
if pattern.search(value):
errors.append(
f"{path}: 疑似包含 {label},知识库只能保存脱敏摘要和证据引用"
)
return errors
def _canonical_scope(scope: Any) -> tuple[Any, ...] | None:
if not isinstance(scope, dict):
return None
values: list[Any] = [scope.get("all")]
for field in SCOPE_FIELDS:
field_values = scope.get(field)
if not isinstance(field_values, list):
return None
values.append(tuple(sorted(item for item in field_values if isinstance(item, str))))
return tuple(values)
def validate_semantics(
data: dict[str, Any], *, project_root: Path | None = None
) -> list[str]:
"""始终执行的跨字段和跨条目语义校验。"""
errors = _find_forbidden_keys(data)
errors.extend(_find_secrets(data))
project = data.get("project")
if isinstance(project, dict) and not _nonempty(project.get("name")):
errors.append("project.name 必填")
entries = data.get("entries")
if not isinstance(entries, list):
return errors
registry = data.get("verificationRegistry")
resolved_root: Path | None = None
if project_root is not None and project_root.is_dir():
try:
resolved_root = project_root.resolve(strict=True)
except OSError as exc:
errors.append(f"项目根目录无法解析: {exc}")
if isinstance(registry, dict):
for ref, target in registry.items():
if not isinstance(target, dict):
continue
path = target.get("path")
if not isinstance(path, str):
continue
segments = path.replace("\\", "/").split("/")
lexically_invalid = (
path.startswith(("/", "\\"))
or re.match(r"^[A-Za-z]:", path)
or "\\" in path
or ".." in segments
or not SAFE_RELATIVE_PATH_RE.fullmatch(path)
)
if lexically_invalid:
errors.append(
f"verificationRegistry.{ref}.path: {path!r} 必须是仓库内相对路径"
)
elif not _nonempty(path):
errors.append(f"verificationRegistry.{ref}.path: 必须是非空字符串")
elif resolved_root is not None:
errors.extend(
_validate_registry_target(
resolved_root,
path,
f"verificationRegistry.{ref}.path",
)
)
by_ref: dict[str, dict[str, Any]] = {}
locations: dict[str, str] = {}
active_by_id: dict[str, str] = {}
active_subject_scopes: dict[tuple[str, tuple[Any, ...]], str] = {}
superseded_targets: set[str] = set()
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
where = f"entries[{index}]"
for field in (
"title",
"appliesWhen",
"directive",
"rationale",
"owner",
"author",
"reviewer",
):
if not _nonempty(entry.get(field)):
errors.append(f"{where}.{field}: 必须是非空字符串")
verification = entry.get("verification")
if isinstance(verification, dict) and not _nonempty(
verification.get("expected")
):
errors.append(f"{where}.verification.expected: 必填")
provenance = entry.get("provenance")
if isinstance(provenance, dict):
for field in ("taskId", "attemptId", "codeRef", "evidenceRef"):
if not _nonempty(provenance.get(field)):
errors.append(f"{where}.provenance.{field}: 必填")
approval = entry.get("approval")
if isinstance(approval, dict):
for field in ("approvedBy", "evidenceRef"):
if not _nonempty(approval.get(field)):
errors.append(f"{where}.approval.{field}: 必填")
ref = stable_ref(entry)
if ref:
if ref in by_ref:
errors.append(f"{where}: 稳定引用 {ref} 重复(首次位于 {locations[ref]}")
else:
by_ref[ref] = entry
locations[ref] = where
if entry.get("status") == "active" and isinstance(entry.get("id"), str):
previous = active_by_id.get(entry["id"])
if previous:
errors.append(
f"{where}: {entry['id']} 同时存在多个 active revision{previous}, {ref}"
)
elif ref:
active_by_id[entry["id"]] = ref
scope = entry.get("scope")
if isinstance(scope, dict):
populated = [
field
for field in SCOPE_FIELDS
if isinstance(scope.get(field), list) and scope.get(field)
]
if scope.get("all") is True and populated:
errors.append(f"{where}.scope: all=true 时不能同时填写作用域维度")
if scope.get("all") is not True and not populated:
errors.append(f"{where}.scope: 必须设置 all=true 或至少一个作用域维度")
if (
scope.get("all") is True
and entry.get("status") == "active"
and not isinstance(entry.get("approval"), dict)
):
errors.append(
f"{where}: 全项目 active 知识必须记录 Decision Owner approval"
)
paths = scope.get("paths")
if isinstance(paths, list):
for pattern in paths:
if not isinstance(pattern, str):
continue
segments = pattern.replace("\\", "/").split("/")
if (
pattern.startswith(("/", "\\"))
or re.match(r"^[A-Za-z]:", pattern)
or "\\" in pattern
or ".." in segments
):
errors.append(
f"{where}.scope.paths: {pattern!r} 必须是仓库内相对 glob"
)
created = _timestamp(entry.get("createdAt"))
validated = _timestamp(entry.get("lastValidatedAt"))
review_after = _timestamp(entry.get("reviewAfter"))
if created and validated and validated < created:
errors.append(f"{where}: lastValidatedAt 不能早于 createdAt")
if validated and review_after and review_after <= validated:
errors.append(f"{where}: reviewAfter 必须晚于 lastValidatedAt")
if (
entry.get("status") == "active"
and review_after is not None
and review_after <= datetime.now().astimezone()
):
errors.append(
f"{where}: active 条目已超过 reviewAfter,必须重新验证或标记 stale"
)
if (
entry.get("status") == "active"
and _nonempty(entry.get("author"))
and entry.get("author") == entry.get("reviewer")
):
errors.append(f"{where}: active 条目的 reviewer 必须独立于 author")
if entry.get("temporary") is True:
if not _nonempty(entry.get("removalCondition")):
errors.append(f"{where}: temporary 条目必须填写 removalCondition")
if review_after is None:
errors.append(f"{where}: temporary 条目必须填写 reviewAfter")
if entry.get("status") in {"stale", "superseded", "archived"} and not _nonempty(
entry.get("statusReason")
):
errors.append(f"{where}: 非 active 条目必须填写 statusReason")
verification = entry.get("verification")
if isinstance(verification, dict) and _nonempty(verification.get("ref")):
verification_ref = verification["ref"]
if not isinstance(registry, dict) or verification_ref not in registry:
errors.append(
f"{where}.verification.ref: {verification_ref!r} 未在 verificationRegistry 注册"
)
if (
entry.get("status") == "active"
and isinstance(entry.get("subject"), str)
and (scope_key := _canonical_scope(scope)) is not None
):
key = (entry["subject"], scope_key)
previous = active_subject_scopes.get(key)
if previous:
errors.append(
f"{where}: 相同 subject 和 scope 已有 active 条目 {previous}"
)
elif ref:
active_subject_scopes[key] = ref
refs = entry.get("supersedes")
if isinstance(refs, list):
superseded_targets.update(ref for ref in refs if isinstance(ref, str))
for ref, entry in by_ref.items():
for field in ("supersedes", "conflictsWith"):
related = entry.get(field)
if not isinstance(related, list):
continue
for target_ref in related:
if not isinstance(target_ref, str) or not REF_RE.fullmatch(target_ref):
continue
if target_ref == ref:
errors.append(f"{locations[ref]}.{field}: 不能引用自身 {ref}")
continue
target = by_ref.get(target_ref)
if target is None:
errors.append(
f"{locations[ref]}.{field}: 找不到精确版本 {target_ref}"
)
continue
if field == "supersedes" and target.get("status") != "superseded":
errors.append(
f"{locations[ref]}.supersedes: {target_ref} 必须标记为 superseded"
)
if (
field == "conflictsWith"
and entry.get("status") == "active"
and target.get("status") == "active"
):
errors.append(
f"{locations[ref]}.conflictsWith: active 条目不能与 active {target_ref} 冲突"
)
for ref, entry in by_ref.items():
if entry.get("status") == "superseded" and ref not in superseded_targets:
errors.append(f"{locations[ref]}: superseded 条目必须被其它 revision 显式引用")
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, format_checker=jsonschema.FormatChecker()
)
errors: list[str] = []
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_all(
data: dict[str, Any],
schema_path: Path,
*,
use_schema: bool | None = None,
project_root: Path | None = None,
) -> tuple[list[str], str]:
"""运行 schema/fallback,并且无条件叠加语义校验。"""
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_structure(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 = "内置结构 + 内置语义"
errors.extend(validate_semantics(data, project_root=project_root))
return list(dict.fromkeys(errors)), mode
def _validate_knowledge_file_binding(
tasks_data: dict[str, Any],
*,
knowledge_path: Path | None,
tasks_path: Path | None,
project_root: Path | None,
project_root_is_explicit: bool,
) -> list[str]:
errors: list[str] = []
project = tasks_data.get("project")
if not isinstance(project, dict):
return ["[tasks] project 必须是对象,无法核对 knowledgeFile"]
knowledge_file = project.get("knowledgeFile")
if not _nonempty(knowledge_file):
return ["[tasks] project.knowledgeFile 必填"]
if knowledge_file != ".pouch/ack/knowledge.yaml":
errors.append(
"[tasks] project.knowledgeFile 必须固定为 "
"'.pouch/ack/knowledge.yaml'"
)
relative = Path(knowledge_file)
segments = knowledge_file.replace("\\", "/").split("/")
if (
relative.is_absolute()
or re.match(r"^[A-Za-z]:", knowledge_file)
or "\\" in knowledge_file
or ".." in segments
):
errors.append("[tasks] project.knowledgeFile 必须是项目内相对路径")
return errors
if knowledge_path is None or tasks_path is None:
return errors
declared_repo_path = project.get("repoPath")
declared_root = _tasks_project_root(tasks_data, tasks_path)
if (
_nonempty(declared_repo_path)
and declared_root is None
and not project_root_is_explicit
):
errors.append(
f"[tasks] project.repoPath={declared_repo_path!r} 不存在或不是目录"
)
binding_root = project_root or declared_root
if binding_root is None or not binding_root.is_dir():
errors.append(
"[tasks] 无法从 .pouch/ack 布局确定现有项目根目录;"
"请传入 --project-root"
)
return errors
try:
resolved_root = binding_root.resolve(strict=True)
expected = (resolved_root / relative).resolve(strict=False)
except (OSError, RuntimeError) as exc:
errors.append(f"[tasks] project.knowledgeFile 无法安全解析: {exc}")
return errors
if not _is_within(expected, resolved_root):
errors.append("[tasks] project.knowledgeFile 经 symlink 解析后逃逸项目根目录")
return errors
try:
actual = knowledge_path.resolve(strict=True)
except (OSError, RuntimeError) as exc:
errors.append(f"[tasks] 当前知识文件无法安全解析: {exc}")
return errors
if actual != expected:
errors.append(
f"[tasks] project.knowledgeFile 指向 {expected}"
f"与当前知识文件 {actual} 不一致"
)
return errors
def _collect_task_attempts(
tasks: list[Any], errors: list[str]
) -> tuple[dict[str, dict[str, Any]], dict[str, set[str]]]:
tasks_by_id: dict[str, dict[str, Any]] = {}
attempts_by_task: dict[str, set[str]] = {}
all_attempts: set[str] = set()
for index, task in enumerate(tasks):
if not isinstance(task, dict) or not _nonempty(task.get("id")):
continue
task_id = task["id"]
if task_id in tasks_by_id:
continue
tasks_by_id[task_id] = task
attempts: set[str] = set()
dispatch = task.get("dispatch")
rounds = dispatch.get("rounds") if isinstance(dispatch, dict) else None
if isinstance(rounds, list):
round_numbers = [
round_item.get("round") if isinstance(round_item, dict) else None
for round_item in rounds
]
expected_rounds = list(range(1, len(rounds) + 1))
if round_numbers != expected_rounds:
errors.append(
f"[tasks] {task_id}.dispatch.rounds: round 必须按 "
f"1..N 连续且不超过 {MAX_ROUNDS}"
)
for round_index, round_item in enumerate(rounds):
if not isinstance(round_item, dict):
continue
attempt_id = round_item.get("attemptId")
if attempt_id is None:
continue
where = (
f"[tasks] {task_id}.dispatch.rounds[{round_index}].attemptId"
)
round_number = round_item.get("round")
if (
not isinstance(round_number, int)
or isinstance(round_number, bool)
or not 1 <= round_number <= MAX_ROUNDS
):
errors.append(
f"{where}: round 必须先是 1..{MAX_ROUNDS} 的整数"
)
continue
expected = f"{task_id}-A{round_number}"
if attempt_id != expected:
errors.append(
f"{where}: 必须精确等于 {expected!r},不能使用 dispatchId"
)
continue
if attempt_id in attempts or attempt_id in all_attempts:
errors.append(f"{where}: attemptId 重复 {attempt_id!r}")
continue
attempts.add(attempt_id)
all_attempts.add(attempt_id)
attempts_by_task[task_id] = attempts
return tasks_by_id, attempts_by_task
def validate_task_references(
knowledge: dict[str, Any],
tasks_data: dict[str, Any],
*,
knowledge_path: Path | None = None,
tasks_path: Path | None = None,
project_root: Path | None = None,
project_root_is_explicit: bool = False,
) -> list[str]:
"""校验 tasks.yaml 对固定知识版本的引用和 verified gate。"""
errors = _validate_knowledge_file_binding(
tasks_data,
knowledge_path=knowledge_path,
tasks_path=tasks_path,
project_root=project_root,
project_root_is_explicit=project_root_is_explicit,
)
knowledge_project = knowledge.get("project")
tasks_project = tasks_data.get("project")
if isinstance(knowledge_project, dict) and isinstance(tasks_project, dict):
knowledge_name = knowledge_project.get("name")
tasks_name = tasks_project.get("name")
if (
isinstance(knowledge_name, str)
and isinstance(tasks_name, str)
and knowledge_name != tasks_name
):
errors.append(
f"[tasks] project.name={tasks_name!r} 与知识库项目 "
f"{knowledge_name!r} 不一致"
)
entries = knowledge.get("entries")
if not isinstance(entries, list):
return errors
by_ref = {
ref: entry
for entry in entries
if isinstance(entry, dict) and (ref := stable_ref(entry)) is not None
}
tasks = tasks_data.get("tasks")
if not isinstance(tasks, list):
return ["[tasks] tasks 必须是列表"]
tasks_by_id, attempts_by_task = _collect_task_attempts(tasks, errors)
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
provenance = entry.get("provenance")
if not isinstance(provenance, dict):
continue
task_id = provenance.get("taskId")
attempt_id = provenance.get("attemptId")
where = f"[knowledge] entries[{index}].provenance"
if not _nonempty(task_id) or task_id not in tasks_by_id:
errors.append(f"{where}.taskId: 在 tasks.yaml 中找不到 {task_id!r}")
continue
if (
not _nonempty(attempt_id)
or attempt_id not in attempts_by_task.get(task_id, set())
):
errors.append(
f"{where}.attemptId: {attempt_id!r} 未命中任务 {task_id!r} "
"dispatch.rounds[].attemptId"
)
for index, task in enumerate(tasks):
if not isinstance(task, dict):
continue
task_id = task.get("id") or index
where = f"[tasks] {task_id}"
requires_active = task.get("status") not in TERMINAL_TASK_STATUSES
raw_refs = task.get("knowledgeRefs", [])
if not isinstance(raw_refs, list):
errors.append(f"{where}.knowledgeRefs: 必须是列表")
continue
selected_refs: set[str] = set()
for ref in raw_refs:
if not isinstance(ref, str) or not REF_RE.fullmatch(ref):
errors.append(f"{where}.knowledgeRefs: {ref!r} 不是 K-...@revision")
continue
if ref in selected_refs:
errors.append(f"{where}.knowledgeRefs: 重复引用 {ref}")
continue
selected_refs.add(ref)
entry = by_ref.get(ref)
if entry is None:
errors.append(f"{where}.knowledgeRefs: 找不到精确版本 {ref}")
elif requires_active and entry.get("status") != "active":
errors.append(
f"{where}.knowledgeRefs: {ref} 状态为 {entry.get('status')!r},必须 active"
)
applied = task.get("knowledgeApplied", [])
if not isinstance(applied, list):
errors.append(f"{where}.knowledgeApplied: 必须是对象列表")
else:
seen_applied: set[str] = set()
for item in applied:
if not isinstance(item, dict):
errors.append(f"{where}.knowledgeApplied: 每项必须是对象")
continue
ref = item.get("ref")
if not isinstance(ref, str) or not REF_RE.fullmatch(ref):
errors.append(f"{where}.knowledgeApplied: ref 必须是 K-...@revision")
continue
if ref in seen_applied:
errors.append(f"{where}.knowledgeApplied: 重复结果 {ref}")
seen_applied.add(ref)
if ref not in selected_refs:
errors.append(f"{where}.knowledgeApplied: {ref} 未列入 knowledgeRefs")
if ref not in by_ref:
errors.append(f"{where}.knowledgeApplied: 找不到 {ref}")
elif requires_active and by_ref[ref].get("status") != "active":
errors.append(f"{where}.knowledgeApplied: {ref} 不是 active")
if item.get("result") not in APPLIED_RESULTS:
errors.append(
f"{where}.knowledgeApplied[{ref}].result: 应为 {sorted(APPLIED_RESULTS)}"
)
checks = task.get("knowledgeChecks", [])
passed_checks: set[str] = set()
if not isinstance(checks, list):
errors.append(f"{where}.knowledgeChecks: 必须是对象列表")
else:
seen_checks: set[str] = set()
for check in checks:
if not isinstance(check, dict):
errors.append(f"{where}.knowledgeChecks: 每项必须是对象")
continue
ref = check.get("ref")
if not isinstance(ref, str) or not REF_RE.fullmatch(ref):
errors.append(f"{where}.knowledgeChecks: ref 必须是 K-...@revision")
continue
if ref in seen_checks:
errors.append(f"{where}.knowledgeChecks: 重复结果 {ref}")
seen_checks.add(ref)
if ref not in selected_refs:
errors.append(f"{where}.knowledgeChecks: {ref} 未列入 knowledgeRefs")
if ref not in by_ref:
errors.append(f"{where}.knowledgeChecks: 找不到 {ref}")
elif requires_active and by_ref[ref].get("status") != "active":
errors.append(f"{where}.knowledgeChecks: {ref} 不是 active")
result = check.get("result")
if result not in CHECK_RESULTS:
errors.append(
f"{where}.knowledgeChecks[{ref}].result: 应为 {sorted(CHECK_RESULTS)}"
)
elif result == "passed":
passed_checks.add(ref)
if task.get("status") == "verified":
for ref in selected_refs:
entry = by_ref.get(ref)
if (
entry is not None
and ref not in passed_checks
):
errors.append(
f"{where}: verified 任务的 knowledgeRef {ref} 缺少 passed knowledgeCheck"
)
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 ACK knowledge.yaml")
parser.add_argument(
"knowledge", nargs="?", default="knowledge.yaml", help="知识库路径"
)
parser.add_argument("--schema", help="knowledge.schema.json 路径")
parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验")
parser.add_argument(
"--project-root",
help="可选项目根目录;默认从 tasks.yaml 的 .pouch/ack 布局推断",
)
args = parser.parse_args(argv)
knowledge_path = Path(args.knowledge)
if not knowledge_path.is_file():
sys.stderr.write(f"找不到知识库文件: {knowledge_path}\n")
return 2
schema_path = (
Path(args.schema)
if args.schema
else Path(__file__).resolve().parent.parent
/ "templates"
/ "knowledge.schema.json"
)
if args.schema and not schema_path.is_file():
sys.stderr.write(f"找不到 schema 文件: {schema_path}\n")
return 2
tasks_path: Path | None = None
tasks_data: dict[str, Any] | None = None
if args.tasks:
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
return 2
tasks_data = load_yaml(tasks_path, "任务板")
project_root_is_explicit = bool(args.project_root)
if project_root_is_explicit:
project_root = Path(args.project_root).expanduser()
if not project_root.is_dir():
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
return 2
project_root = project_root.resolve()
else:
project_root = (
_tasks_project_root(tasks_data, tasks_path)
if tasks_data is not None
else None
)
if project_root is None:
project_root = infer_project_root(knowledge_path)
data = load_yaml(knowledge_path, "知识库")
errors, mode = validate_all(
data, schema_path, project_root=project_root
)
registry = data.get("verificationRegistry")
if (
project_root is None
and isinstance(registry, dict)
and registry
):
errors.append(
"verificationRegistry 非空但无法确定项目根目录;请传入 --project-root"
)
if tasks_data is not None:
errors.extend(
validate_task_references(
data,
tasks_data,
knowledge_path=knowledge_path,
tasks_path=tasks_path,
project_root=project_root,
project_root_is_explicit=project_root_is_explicit,
)
)
mode += " + tasks 引用"
errors = list(dict.fromkeys(errors))
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}):{knowledge_path}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())