f3cd56b78e
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.
1157 lines
44 KiB
Python
Executable File
1157 lines
44 KiB
Python
Executable File
#!/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())
|