feat(ack): add project knowledge guardrails

This commit is contained in:
2026-07-31 21:09:36 +08:00
parent 7d1994cf93
commit ee66dbe9ce
32 changed files with 8353 additions and 139 deletions
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""安全执行 knowledge.yaml 中已审查的 verificationRegistry 条目。
本入口只接受 registry ID,不接受额外命令或参数。执行前只打开一次项目根目录
fd,知识库、检查目标和子进程 cwd 都固定到该 fd;检查文件逐段以 O_NOFOLLOW
打开后复制到匿名、尽可能 sealed 的稳定快照,再使用结构化 argv 和 shell=False
启动,避免检查与执行之间被替换。
退出码: 0..125 沿用检查结果 / 1 知识或执行失败 / 2 环境、路径或用法错误。
"""
from __future__ import annotations
import argparse
import fcntl
import os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
from validate_knowledge import ( # type: ignore
infer_project_root,
load_yaml_text,
validate_all,
)
MAX_KNOWLEDGE_BYTES = 16 * 1024 * 1024
MAX_TARGET_BYTES = 16 * 1024 * 1024
def _project_root(
value: str | None,
knowledge_path: Path,
) -> tuple[Path | None, str | None]:
inferred = infer_project_root(knowledge_path)
if value is not None:
candidate = Path(value).expanduser()
if not candidate.is_dir():
return None, f"项目根目录不存在: {candidate}"
resolved = candidate.resolve(strict=True)
if inferred is not None and resolved != inferred:
return (
None,
f"--project-root {resolved} 与 knowledge.yaml 推断的项目根目录 "
f"{inferred} 不一致",
)
return resolved, None
if inferred is None:
return None, "无法从 knowledge.yaml 确定现有项目根目录"
return inferred, None
def _validate_knowledge_location(
knowledge_path: Path,
project_root: Path,
) -> str | None:
expected = project_root / "docs" / "ack" / "knowledge.yaml"
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
if lexical != expected:
return (
"只允许执行项目权威知识库 "
f"{expected},当前输入为 {lexical}"
)
return None
def _open_regular_beneath(
project_root: Path | int,
relative_path: str,
*,
require_executable: bool,
) -> tuple[int | None, str | None]:
"""从根目录 fd 逐段打开目标,不允许任一段通过 symlink 跳转。"""
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
return None, "当前平台不支持安全的 O_NOFOLLOW/O_DIRECTORY 路径解析"
parts = PurePosixPath(relative_path).parts
if (
not parts
or PurePosixPath(relative_path).is_absolute()
or any(part in {"", ".", ".."} for part in parts)
):
return None, "检查目标必须是规范的项目内相对路径"
directory_fds: list[int] = []
target_fd: int | None = None
try:
root_fd = (
os.dup(project_root)
if isinstance(project_root, int)
else os.open(
project_root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
)
directory_fds.append(root_fd)
current_fd = root_fd
for segment in parts[:-1]:
current_fd = os.open(
segment,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=current_fd,
)
directory_fds.append(current_fd)
target_fd = os.open(
parts[-1],
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=current_fd,
)
metadata = os.fstat(target_fd)
if not stat.S_ISREG(metadata.st_mode):
os.close(target_fd)
return None, "检查目标不是普通文件"
if require_executable and metadata.st_mode & 0o111 == 0:
os.close(target_fd)
return None, "检查目标不可执行"
return target_fd, None
except OSError as exc:
if target_fd is not None:
os.close(target_fd)
return None, f"检查目标不存在、不可访问或路径包含软链接: {exc}"
finally:
for directory_fd in reversed(directory_fds):
os.close(directory_fd)
def _stable_metadata(before: os.stat_result, after: os.stat_result) -> bool:
fields = (
"st_dev",
"st_ino",
"st_mode",
"st_size",
"st_mtime_ns",
"st_ctime_ns",
)
return all(getattr(before, field) == getattr(after, field) for field in fields)
def _read_stable_bytes(
source_fd: int,
*,
maximum: int,
) -> tuple[bytes | None, str | None]:
before = os.fstat(source_fd)
if before.st_size > maximum:
return None, f"知识库超过大小上限 {maximum} bytes"
chunks: list[bytes] = []
total = 0
try:
os.lseek(source_fd, 0, os.SEEK_SET)
while True:
chunk = os.read(source_fd, min(1024 * 1024, maximum - total + 1))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > maximum:
return None, f"知识库超过大小上限 {maximum} bytes"
except OSError as exc:
return None, f"无法读取权威知识库快照: {exc}"
after = os.fstat(source_fd)
if not _stable_metadata(before, after):
return None, "权威知识库在读取期间发生变化,拒绝执行"
return b"".join(chunks), None
def load_authoritative_knowledge(
project_root: Path | int,
) -> tuple[dict[str, Any] | None, str | None]:
source_fd, open_error = _open_regular_beneath(
project_root,
"docs/ack/knowledge.yaml",
require_executable=False,
)
if open_error is not None or source_fd is None:
return None, open_error or "无法安全打开权威知识库"
try:
content, read_error = _read_stable_bytes(
source_fd,
maximum=MAX_KNOWLEDGE_BYTES,
)
finally:
os.close(source_fd)
if read_error is not None or content is None:
return None, read_error or "无法读取权威知识库"
try:
text = content.decode("utf-8")
except UnicodeDecodeError as exc:
return None, f"权威知识库不是有效 UTF-8: {exc}"
return load_yaml_text(text, "知识库"), None
def _write_all(file_descriptor: int, chunk: bytes) -> None:
remaining = memoryview(chunk)
while remaining:
written = os.write(file_descriptor, remaining)
if written <= 0:
raise OSError("无法写入检查快照")
remaining = remaining[written:]
def _snapshot_executable(source_fd: int) -> tuple[int | None, str | None]:
"""复制到匿名快照;Linux 上进一步 seal,冻结本次执行内容。"""
before = os.fstat(source_fd)
if before.st_size > MAX_TARGET_BYTES:
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
snapshot_fd: int | None = None
seal_snapshot = all(
hasattr(owner, name)
for owner, name in (
(os, "memfd_create"),
(os, "MFD_ALLOW_SEALING"),
(os, "MFD_CLOEXEC"),
(fcntl, "F_ADD_SEALS"),
(fcntl, "F_SEAL_SEAL"),
(fcntl, "F_SEAL_SHRINK"),
(fcntl, "F_SEAL_GROW"),
(fcntl, "F_SEAL_WRITE"),
)
)
try:
if seal_snapshot:
snapshot_fd = os.memfd_create( # type: ignore[attr-defined]
"ack-verification",
os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING, # type: ignore[attr-defined]
)
else:
snapshot_fd, snapshot_path = tempfile.mkstemp(
prefix="ack-verification-"
)
os.unlink(snapshot_path)
os.lseek(source_fd, 0, os.SEEK_SET)
total = 0
while True:
chunk = os.read(
source_fd,
min(1024 * 1024, MAX_TARGET_BYTES - total + 1),
)
if not chunk:
break
_write_all(snapshot_fd, chunk)
total += len(chunk)
if total > MAX_TARGET_BYTES:
os.close(snapshot_fd)
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
after = os.fstat(source_fd)
if not _stable_metadata(before, after):
os.close(snapshot_fd)
return None, "检查目标在创建执行快照期间发生变化,拒绝执行"
os.fchmod(snapshot_fd, before.st_mode & 0o777)
os.lseek(snapshot_fd, 0, os.SEEK_SET)
if seal_snapshot:
seals = (
fcntl.F_SEAL_SEAL
| fcntl.F_SEAL_SHRINK
| fcntl.F_SEAL_GROW
| fcntl.F_SEAL_WRITE
)
fcntl.fcntl(snapshot_fd, fcntl.F_ADD_SEALS, seals)
return snapshot_fd, None
except OSError as exc:
if snapshot_fd is not None:
os.close(snapshot_fd)
return None, f"无法创建稳定的检查执行快照: {exc}"
def open_target(
data: dict[str, Any],
verification_ref: str,
project_root: Path | int,
) -> tuple[int | None, list[str] | None, str | None]:
registry = data.get("verificationRegistry")
if not isinstance(registry, dict) or verification_ref not in registry:
return None, None, f"verification.ref {verification_ref!r} 未在 registry 注册"
target = registry.get(verification_ref)
if not isinstance(target, dict):
return None, None, f"verificationRegistry.{verification_ref} 不是对象"
relative_path = target.get("path")
args = target.get("args")
if not isinstance(relative_path, str) or not isinstance(args, list):
return None, None, f"verificationRegistry.{verification_ref} 结构无效"
if any(not isinstance(arg, str) for arg in args):
return (
None,
None,
f"verificationRegistry.{verification_ref}.args 必须是字符串数组",
)
source_fd, error = _open_regular_beneath(
project_root,
relative_path,
require_executable=True,
)
if error is not None or source_fd is None:
return None, None, error or "无法安全打开检查目标"
try:
target_fd, snapshot_error = _snapshot_executable(source_fd)
finally:
os.close(source_fd)
if snapshot_error is not None or target_fd is None:
return None, None, snapshot_error or "无法创建检查执行快照"
return target_fd, args, None
def _fd_executable_path(target_fd: int) -> str | None:
for prefix in ("/proc/self/fd", "/dev/fd"):
candidate = f"{prefix}/{target_fd}"
if Path(candidate).exists():
return candidate
return None
def _fd_directory_path(directory_fd: int) -> str | None:
for prefix in ("/proc/self/fd", "/dev/fd"):
candidate = f"{prefix}/{directory_fd}"
if Path(candidate).is_dir():
return candidate
return None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="执行 ACK verificationRegistry 中已审查的检查"
)
parser.add_argument("knowledge", help="knowledge.yaml 路径")
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
parser.add_argument(
"--project-root",
help="项目根目录;默认从 knowledge.yaml 的 docs/ack 布局或 Git 推断",
)
args = parser.parse_args(argv)
knowledge_path = Path(args.knowledge)
project_root, root_error = _project_root(args.project_root, knowledge_path)
if project_root is None:
sys.stderr.write(f"{root_error or '无法确定现有项目根目录'}\n")
return 2
location_error = _validate_knowledge_location(knowledge_path, project_root)
if location_error is not None:
sys.stderr.write(f"{location_error}\n")
return 2
try:
project_root_fd = os.open(
project_root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except OSError as exc:
sys.stderr.write(f"无法安全打开项目根目录: {exc}\n")
return 2
try:
stable_root_path = _fd_directory_path(project_root_fd)
if stable_root_path is None:
sys.stderr.write("当前平台无法固定项目根目录文件描述符\n")
return 2
data, knowledge_error = load_authoritative_knowledge(project_root_fd)
if knowledge_error is not None or data is None:
sys.stderr.write(f"{knowledge_error or '无法读取权威知识库'}\n")
return 2
schema_path = (
Path(__file__).resolve().parent.parent
/ "templates"
/ "knowledge.schema.json"
)
errors, mode = validate_all(
data,
schema_path,
project_root=Path(stable_root_path),
)
if errors:
sys.stderr.write(f"知识库校验失败({mode}),拒绝执行:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
target_fd, target_args, error = open_target(
data,
args.verification_ref,
project_root_fd,
)
if error is not None or target_fd is None or target_args is None:
sys.stderr.write(f"{error or '无法解析检查目标'}\n")
return 2
try:
executable_path = _fd_executable_path(target_fd)
if executable_path is None:
sys.stderr.write(
"当前平台无法从已打开的文件描述符安全执行检查\n"
)
return 2
environment = os.environ.copy()
environment["ACK_PROJECT_ROOT"] = stable_root_path
environment["ACK_PROJECT_ROOT_DISPLAY"] = str(project_root)
environment["ACK_VERIFICATION_REF"] = args.verification_ref
registry = data.get("verificationRegistry")
target = (
registry.get(args.verification_ref)
if isinstance(registry, dict)
else None
)
if isinstance(target, dict) and isinstance(target.get("path"), str):
environment["ACK_VERIFICATION_PATH"] = target["path"]
try:
completed = subprocess.run(
[executable_path, *target_args],
cwd=stable_root_path,
env=environment,
shell=False,
check=False,
pass_fds=(project_root_fd, target_fd),
)
except OSError as exc:
sys.stderr.write(f"检查启动失败: {exc}\n")
return 1
finally:
os.close(target_fd)
finally:
os.close(project_root_fd)
if 0 <= completed.returncode <= 125:
return completed.returncode
return 1
if __name__ == "__main__":
raise SystemExit(main())
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""按显式项目上下文确定性选择 active ACK 知识。
匹配采用大小写敏感 glob;entry 中每个非空 scope 维度都必须被查询上下文命中。
scope.all=true 的条目始终命中并优先占用 --limit,数量超过预算时显式失败。其余
结果按作用域具体程度及稳定引用排序。本脚本只输出数据,绝不执行 knowledge.yaml
中的任何文本。
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any
from validate_knowledge import ( # type: ignore
SCOPE_FIELDS,
infer_project_root,
load_yaml,
stable_ref,
validate_builtin_structure,
validate_semantics,
)
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
def _path_glob_matches(value: str, pattern: str) -> bool:
"""路径 glob:* 只匹配单段,只有完整的 ** 段可以跨越 /。"""
value_parts = tuple(value.split("/"))
pattern_parts = tuple(pattern.split("/"))
@lru_cache(maxsize=None)
def match(pattern_index: int, value_index: int) -> bool:
if pattern_index == len(pattern_parts):
return value_index == len(value_parts)
pattern_part = pattern_parts[pattern_index]
if pattern_part == "**":
return match(pattern_index + 1, value_index) or (
value_index < len(value_parts)
and match(pattern_index, value_index + 1)
)
return (
value_index < len(value_parts)
and fnmatch.fnmatchcase(value_parts[value_index], pattern_part)
and match(pattern_index + 1, value_index + 1)
)
return match(0, 0)
def scope_matches(
scope: dict[str, Any], context: dict[str, list[str]]
) -> bool:
if scope.get("all") is True:
return True
constrained = False
for field in SCOPE_FIELDS:
patterns = scope.get(field)
if not isinstance(patterns, list) or not patterns:
continue
constrained = True
values = context.get(field, [])
matcher = _path_glob_matches if field == "paths" else fnmatch.fnmatchcase
if not values or not any(
matcher(value, pattern)
for pattern in patterns
if isinstance(pattern, str)
for value in values
):
return False
return constrained
def _pattern_specificity(pattern: str) -> tuple[int, int, int, int, int]:
wildcard_count = sum(pattern.count(char) for char in ("*", "?", "["))
double_star_count = sum(1 for part in pattern.split("/") if part == "**")
literal_count = sum(char not in "*?[]!" for char in pattern)
exact = int(wildcard_count == 0)
depth = len(pattern.split("/"))
return (exact, literal_count, -double_star_count, -wildcard_count, depth)
def _specificity(entry: dict[str, Any]) -> tuple[int, int, int, int, int, int, int]:
scope = entry.get("scope")
if not isinstance(scope, dict) or scope.get("all") is True:
return (0, 0, 0, 0, 0, 0, 0)
populated = 0
exact = 0
literal = 0
double_star = 0
wildcard = 0
depth = 0
extra_or_patterns = 0
for field in SCOPE_FIELDS:
values = scope.get(field)
if isinstance(values, list) and values:
populated += 1
scores = [
_pattern_specificity(value)
for value in values
if isinstance(value, str)
]
if scores:
dimension_score = min(scores)
exact += dimension_score[0]
literal += dimension_score[1]
double_star += dimension_score[2]
wildcard += dimension_score[3]
depth += dimension_score[4]
extra_or_patterns += len(scores) - 1
return (
populated,
exact,
literal,
double_star,
wildcard,
depth,
-extra_or_patterns,
)
def select_entries(
data: dict[str, Any],
context: dict[str, list[str]],
*,
limit: int = DEFAULT_LIMIT,
) -> list[dict[str, Any]]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
raise ValueError(f"limit 必须在 1..{MAX_LIMIT} 之间")
entries = data.get("entries")
if not isinstance(entries, list):
return []
matched = [
entry
for entry in entries
if isinstance(entry, dict)
and entry.get("status") == "active"
and isinstance(entry.get("scope"), dict)
and scope_matches(entry["scope"], context)
and stable_ref(entry) is not None
]
global_entries = [
entry
for entry in matched
if isinstance(entry.get("scope"), dict)
and entry["scope"].get("all") is True
]
scoped_entries = [entry for entry in matched if entry not in global_entries]
global_entries.sort(key=lambda entry: stable_ref(entry) or "")
if len(global_entries) > limit:
raise ValueError(
f"命中的全项目知识有 {len(global_entries)} 条,超过 --limit={limit}"
"提高 limit 后重试,不能静默丢弃全项目护栏"
)
scoped_entries.sort(
key=lambda entry: (
*(-part for part in _specificity(entry)),
stable_ref(entry) or "",
)
)
return [
*global_entries,
*scoped_entries[: limit - len(global_entries)],
]
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="选择当前任务适用的 active ACK 知识")
parser.add_argument(
"knowledge", nargs="?", default="knowledge.yaml", help="知识库路径"
)
parser.add_argument("--component", action="append", default=[])
parser.add_argument("--path", action="append", default=[])
parser.add_argument("--dependency", action="append", default=[])
parser.add_argument("--version", action="append", default=[])
parser.add_argument("--tag", action="append", default=[])
parser.add_argument("--symbol", action="append", default=[])
parser.add_argument("--error-signature", action="append", default=[])
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument(
"--project-root",
help="可选项目根目录,用于 verificationRegistry symlink containment 校验",
)
parser.add_argument(
"--format", choices=("json", "refs"), default="json", dest="output_format"
)
return parser
def main(argv: list[str] | None = None) -> int:
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
if not 1 <= args.limit <= MAX_LIMIT:
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
return 2
if args.project_root:
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 = infer_project_root(knowledge_path)
data = load_yaml(knowledge_path, "知识库")
errors = validate_builtin_structure(data)
errors.extend(validate_semantics(data, project_root=project_root))
registry = data.get("verificationRegistry")
if project_root is None and isinstance(registry, dict) and registry:
errors.append(
"verificationRegistry 非空但无法确定项目根目录;请传入 --project-root"
)
errors = list(dict.fromkeys(errors))
if errors:
sys.stderr.write(f"知识库无效,拒绝选择,共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
context = {
"components": args.component,
"paths": args.path,
"dependencies": args.dependency,
"versions": args.version,
"tags": args.tag,
"symbols": args.symbol,
"errorSignatures": args.error_signature,
}
try:
selected = select_entries(data, context, limit=args.limit)
except ValueError as exc:
sys.stderr.write(f"知识选择失败: {exc}\n")
return 1
refs = [stable_ref(entry) for entry in selected]
if args.output_format == "refs":
if refs:
sys.stdout.write("\n".join(ref for ref in refs if ref) + "\n")
return 0
payload = {
"count": len(selected),
"limit": args.limit,
"refs": refs,
"entries": [
{
"ref": stable_ref(entry),
**entry,
"verificationTarget": (
data.get("verificationRegistry", {}).get(
entry.get("verification", {}).get("ref")
)
if isinstance(data.get("verificationRegistry"), dict)
and isinstance(entry.get("verification"), dict)
else None
),
}
for entry in selected
],
}
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+555 -38
View File
@@ -3,9 +3,11 @@
权威结构是同目录上层的 templates/tasks.schema.json(跨语言可用)。
本脚本是参考实现:
- 若安装了 jsonschema,则用 schema 做完整校验;
- 否则回退到内置的关键规则校验(必填字段、状态枚举、三轮上限、leftover 留档)。
YAML 解析优先用 pyyaml;未安装时给出提示而非崩溃
- 始终执行内置语义校验;
- 安装了 jsonschema 时,再叠加 schema 结构校验;
- knowledge 字段会检查引用格式、候选结构和 Test 检查结果
YAML 优先使用 PyYAML;未安装时使用 fail-closed 的 ACK YAML 子集。
JSON 任务板只使用标准库,两种格式都拒绝重复键。
用法:
python3 validate_tasks.py [tasks.yaml]
@@ -18,9 +20,18 @@ from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
STATUS_ENUM = {
"open",
"dispatched",
@@ -32,29 +43,337 @@ STATUS_ENUM = {
"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]*$")
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
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",
}
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
"kind",
"title",
"claim",
"scope",
"appliesWhen",
"directive",
"rationale",
"evidenceRefs",
}
KNOWLEDGE_CANDIDATE_TEXT_FIELDS = {
"title",
"claim",
"appliesWhen",
"directive",
"rationale",
}
def load_yaml(path: Path) -> dict:
def _nonempty_string(value: object) -> bool:
return isinstance(value, str) and bool(value.strip())
def load_document(path: Path) -> dict:
try:
import yaml # type: ignore
except ImportError:
sys.stderr.write(
"需要 PyYAML 才能解析 YAMLpip install pyyaml\n"
"(或把任务板导出为 JSON 后再校验)\n"
)
raise SystemExit(2)
try:
with path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"YAML 解析失败: {exc}\n")
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-<id>@<revision> 格式")
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-<id>@<revision> 格式"
)
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-<id>@<revision> 格式")
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_with_schema(data: dict, schema_path: Path) -> list[str]:
import jsonschema # type: ignore
@@ -70,11 +389,92 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
def validate_builtin(data: dict) -> list[str]:
errors: list[str] = []
if not isinstance(data.get("version"), int) or data.get("version", 0) < 1:
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"},
"<root>",
)
project = data.get("project")
if not isinstance(project, dict) or not project.get("name"):
errors.append("project.name 必填")
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"},
"project",
)
if (
"knowledgeFile" in project
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
):
errors.append(
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
)
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):
@@ -90,34 +490,148 @@ def validate_builtin(data: dict) -> list[str]:
tid = task.get("id")
title = task.get("title")
status = task.get("status")
if not tid:
errors.append(f"{where}: id 必")
if not _nonempty_string(tid):
errors.append(f"{where}: id 必须是非空字符串")
else:
where = f"tasks[{i}] {tid}"
if tid in seen_ids:
errors.append(f"{where}: id 重复")
seen_ids.add(tid)
if not title:
errors.append(f"{where}: title 必")
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)}"
)
dispatch = task.get("dispatch") or {}
rounds = dispatch.get("rounds") or []
if isinstance(rounds, list):
validate_string_fields(
task,
{
"type",
"priority",
"assignee",
"component",
"description",
"expected",
"actual",
},
where,
)
validate_string_lists(
task,
{"specRefs", "testRefs", "stepsToReproduce"},
where,
)
validate_object_fields(task, {"evidence", "verification"}, where)
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"]
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}"
)
for r in rounds:
if isinstance(r, dict) and r.get("result") not in {"passed", "failed"}:
errors.append(f"{where}: round.result 必须是 passed/failed")
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: "
"必须使用 <task-id>-A<round> 格式"
)
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 连续递增且不重复"
)
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":
resolution = task.get("resolution") or {}
if not resolution.get("leftoverReason"):
if (
not isinstance(resolution, dict)
or not _nonempty_string(resolution.get("leftoverReason"))
):
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
return errors
@@ -134,24 +648,27 @@ def main(argv: list[str] | None = None) -> int:
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
return 2
data = load_yaml(tasks_path)
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
mode = "内置规则"
errors = validate_builtin(data)
mode = "内置语义规则"
try:
import jsonschema # type: ignore # noqa: F401
if schema_path.is_file():
errors = validate_with_schema(data, schema_path)
mode = f"schema ({schema_path.name})"
errors = validate_with_schema(data, schema_path) + errors
mode = f"schema ({schema_path.name}) + 内置语义规则"
else:
errors = validate_builtin(data)
mode = "内置规则(未找到 schema 文件)"
mode = "内置语义规则(未找到 schema 文件)"
except ImportError:
errors = validate_builtin(data)
pass
if errors:
sys.stderr.write(f"任务板校验失败({mode}),共 {len(errors)} 项:\n")
+686
View File
@@ -0,0 +1,686 @@
#!/usr/bin/env python3
"""ACK YAML 的零依赖、fail-closed 子集解析器。
这不是通用 YAML 实现它只覆盖 ACK 状态文件所需的 mapping
sequenceflow collection标量和 ``>`` / ``|`` block scalar锚点
aliastag多文档和其它未实现语法会显式失败不会猜测或静默误解析
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
class YamlSubsetError(ValueError):
"""YAML 超出 ACK 子集或语法无效。"""
class DuplicateKeyError(ValueError):
"""JSON/YAML mapping 包含重复键。"""
@dataclass(frozen=True)
class _Line:
number: int
indent: int
content: str
_DECIMAL_INT_RE = re.compile(r"[-+]?(?:0|[1-9][0-9]*)\Z")
_AMBIGUOUS_NUMBER_RE = re.compile(
r"[-+]?(?:"
r"[0-9][0-9_]*\.[0-9_]*(?:[eE][-+]?[0-9]+)?|"
r"[0-9][0-9_]*(?:[eE][-+]?[0-9]+)|"
r"0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|"
r"0[0-9_]+|[0-9][0-9_]*:[0-9_:]+"
r")\Z"
)
_PLAIN_KEY_FORBIDDEN_RE = re.compile(r"[\[\]{},#]")
_ANCHOR_OR_ALIAS_RE = re.compile(
r"(?:^|\s)[&*][A-Za-z0-9_-]+(?:\s|$)"
)
def load_json_unique(content: str) -> Any:
"""Parse JSON while rejecting duplicate object keys at every depth."""
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise DuplicateKeyError(f"JSON 存在重复键 {key!r}")
result[key] = value
return result
return json.loads(content, object_pairs_hook=unique_object)
def make_unique_pyyaml_loader(yaml_module: Any) -> type:
"""Build a SafeLoader that rejects duplicate keys and graph features.
ACK documents are trees. Anchors/aliases can introduce shared identity or
cycles, which are unnecessary here and make recursive validation unsafe.
Explicit tags are also outside the fallback grammar, so both code paths
reject them consistently.
"""
class UniqueKeySafeLoader(yaml_module.SafeLoader): # type: ignore[misc]
def compose_node(self, parent: Any, index: Any) -> Any:
if self.check_event(yaml_module.events.AliasEvent):
event = self.peek_event()
raise yaml_module.YAMLError(
f"ACK YAML 不支持 alias: *{event.anchor}"
)
event = self.peek_event()
if getattr(event, "anchor", None) is not None:
raise yaml_module.YAMLError(
f"ACK YAML 不支持 anchor: &{event.anchor}"
)
if getattr(event, "tag", None) is not None:
raise yaml_module.YAMLError(
f"ACK YAML 不支持显式 tag: {event.tag}"
)
return super().compose_node(parent, index)
def construct_unique_mapping(
loader: Any,
node: Any,
deep: bool = False,
) -> dict[str, Any]:
for key_node, _ in node.value:
if (
getattr(key_node, "tag", None) == "tag:yaml.org,2002:merge"
or getattr(key_node, "value", None) == "<<"
):
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"merge keys are not supported",
key_node.start_mark,
)
loader.flatten_mapping(node)
mapping: dict[str, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if not isinstance(key, str):
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"mapping key must be a string",
key_node.start_mark,
)
if key == "<<":
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"merge keys are not supported",
key_node.start_mark,
)
if key in mapping:
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueKeySafeLoader.add_constructor(
yaml_module.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
construct_unique_mapping,
)
return UniqueKeySafeLoader
def load_yaml_subset(content: str) -> Any:
"""Parse the deliberately small YAML subset used by ACK files."""
return _SubsetParser(content).parse()
class _SubsetParser:
def __init__(self, content: str) -> None:
if content.startswith("\ufeff"):
content = content[1:]
if "\t" in content:
raise YamlSubsetError("ACK YAML 子集不支持 Tab,请使用空格")
self.lines = [
_Line(number, len(raw) - len(raw.lstrip(" ")), raw.lstrip(" "))
for number, raw in enumerate(content.splitlines(), start=1)
]
self.index = 0
def parse(self) -> Any:
self._skip_insignificant()
if self.index >= len(self.lines):
return None
first = self.lines[self.index]
if first.indent != 0:
self._error(first, "顶层不能缩进")
value = self._parse_node(0)
self._skip_insignificant()
if self.index != len(self.lines):
line = self.lines[self.index]
self._error(line, "文档尾部存在无法解析的内容")
return value
def _parse_node(self, indent: int) -> Any:
self._skip_insignificant()
if self.index >= len(self.lines):
return None
line = self.lines[self.index]
if line.indent != indent:
self._error(line, f"期望 {indent} 个空格的缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if self._is_sequence_marker(content):
return self._parse_sequence(indent)
return self._parse_mapping(indent)
def _parse_mapping(self, indent: int) -> dict[str, Any]:
result: dict[str, Any] = {}
while True:
self._skip_insignificant()
if self.index >= len(self.lines):
break
line = self.lines[self.index]
if line.indent < indent:
break
if line.indent > indent:
self._error(line, "mapping 存在意外缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if self._is_sequence_marker(content):
break
self.index += 1
self._consume_mapping_entry(
result,
content,
mapping_indent=indent,
line=line,
)
return result
def _parse_sequence(self, indent: int) -> list[Any]:
result: list[Any] = []
while True:
self._skip_insignificant()
if self.index >= len(self.lines):
break
line = self.lines[self.index]
if line.indent < indent:
break
if line.indent > indent:
self._error(line, "sequence 存在意外缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if not self._is_sequence_marker(content):
break
rest = content[1:].lstrip(" ")
self.index += 1
if not rest:
next_line = self._peek_significant()
if next_line is not None and next_line.indent > indent:
result.append(self._parse_node(next_line.indent))
else:
result.append(None)
continue
if self._is_sequence_marker(rest):
self._error(
line,
"不支持紧凑嵌套 sequence,请把内层 '-' 放到下一行",
)
if self._looks_like_mapping_entry(rest):
mapping_indent = indent + 2
item: dict[str, Any] = {}
self._consume_mapping_entry(
item,
rest,
mapping_indent=mapping_indent,
line=line,
)
while True:
self._skip_insignificant()
continuation = self._peek_significant()
if continuation is None or continuation.indent < mapping_indent:
break
if continuation.indent > mapping_indent:
self._error(
continuation,
"sequence mapping 存在意外缩进",
)
continuation_content = self._without_comment(
continuation.content
).rstrip()
if self._is_sequence_marker(continuation_content):
self._error(
continuation,
"sequence mapping 中需要 key: value",
)
self.index += 1
self._consume_mapping_entry(
item,
continuation_content,
mapping_indent=mapping_indent,
line=continuation,
)
result.append(item)
continue
if rest in {">", "|"}:
result.append(self._parse_block_scalar(indent, rest, line))
else:
result.append(self._parse_inline_value(rest, line))
next_line = self._peek_significant()
if next_line is not None and next_line.indent > indent:
self._error(next_line, "scalar sequence 项后存在意外缩进")
return result
def _consume_mapping_entry(
self,
result: dict[str, Any],
content: str,
*,
mapping_indent: int,
line: _Line,
) -> None:
key_text, value_text = self._split_mapping_entry(content, line)
key = self._parse_key(key_text, line)
if key in result:
self._error(line, f"mapping 存在重复键 {key!r}")
value_text = value_text.strip()
if value_text in {">", "|"}:
value = self._parse_block_scalar(mapping_indent, value_text, line)
elif value_text:
if value_text.startswith((">", "|")):
self._error(
line,
"block scalar 只支持 '>''|',不支持 chomping/indent 指示符",
)
value = self._parse_inline_value(value_text, line)
next_line = self._peek_significant()
if next_line is not None and next_line.indent > mapping_indent:
self._error(next_line, f"{key!r} 的 scalar 后存在意外缩进")
else:
next_line = self._peek_significant()
if next_line is not None and next_line.indent > mapping_indent:
value = self._parse_node(next_line.indent)
else:
value = None
result[key] = value
def _parse_block_scalar(
self,
parent_indent: int,
style: str,
header: _Line,
) -> str:
probe = self.index
while probe < len(self.lines) and not self.lines[probe].content.strip():
probe += 1
if probe >= len(self.lines) or self.lines[probe].indent <= parent_indent:
return ""
block_indent = self.lines[probe].indent
if block_indent <= parent_indent:
self._error(header, "block scalar 内容必须比键更深缩进")
values: list[str] = []
while self.index < len(self.lines):
line = self.lines[self.index]
if not line.content.strip():
values.append("")
self.index += 1
continue
if line.indent < block_indent:
break
values.append(" " * (line.indent - block_indent) + line.content)
self.index += 1
while values and values[-1] == "":
values.pop()
if not values:
return ""
if style == "|":
return "\n".join(values) + "\n"
output = ""
previous: str | None = None
blank_count = 0
for value in values:
if value == "":
blank_count += 1
continue
if previous is None:
output = "\n" * blank_count + value
elif blank_count:
output += "\n" * blank_count + value
elif previous.startswith(" ") or value.startswith(" "):
output += "\n" + value
else:
output += " " + value
previous = value
blank_count = 0
return output + "\n"
def _parse_key(self, text: str, line: _Line) -> str:
text = text.strip()
if not text:
self._error(line, "mapping key 不能为空")
if text[0] in {'"', "'"}:
parsed = _FlowParser(text, line.number).parse_complete_value()
if not isinstance(parsed, str):
self._error(line, "mapping key 必须是字符串")
return parsed
if text == "<<":
self._error(line, "ACK YAML 子集不支持 merge key '<<'")
if text[0] in "-?:!&*%@`" or _PLAIN_KEY_FORBIDDEN_RE.search(text):
self._error(line, f"不支持的 plain mapping key {text!r}")
parsed = _plain_scalar(text, line.number)
if not isinstance(parsed, str):
self._error(line, "mapping key 必须是字符串,特殊标量请加引号")
return text
def _parse_inline_value(self, text: str, line: _Line) -> Any:
parser = _FlowParser(text, line.number)
return parser.parse_complete_value()
def _split_mapping_entry(self, content: str, line: _Line) -> tuple[str, str]:
quote: str | None = None
escaped = False
depth = 0
index = 0
while index < len(content):
char = content[index]
if quote == '"':
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
index += 1
continue
if quote == "'":
if char == "'":
if index + 1 < len(content) and content[index + 1] == "'":
index += 2
continue
quote = None
index += 1
continue
if char in {'"', "'"}:
quote = char
elif char in "[{":
depth += 1
elif char in "]}":
depth -= 1
if depth < 0:
self._error(line, "flow collection 括号不匹配")
elif (
char == ":"
and depth == 0
and (index + 1 == len(content) or content[index + 1].isspace())
):
return content[:index], content[index + 1 :]
index += 1
self._error(line, "mapping 项必须使用 'key: value'")
def _looks_like_mapping_entry(self, content: str) -> bool:
try:
self._split_mapping_entry(content, self.lines[self.index - 1])
except YamlSubsetError:
return False
return True
@staticmethod
def _is_sequence_marker(content: str) -> bool:
return content == "-" or content.startswith("- ")
def _peek_significant(self) -> _Line | None:
probe = self.index
while probe < len(self.lines):
line = self.lines[probe]
if line.content.strip() and not line.content.lstrip().startswith("#"):
return line
probe += 1
return None
def _skip_insignificant(self) -> None:
while self.index < len(self.lines):
content = self.lines[self.index].content
if content.strip() and not content.lstrip().startswith("#"):
break
self.index += 1
def _without_comment(self, content: str) -> str:
quote: str | None = None
escaped = False
index = 0
while index < len(content):
char = content[index]
if quote == '"':
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
elif quote == "'":
if char == "'":
if index + 1 < len(content) and content[index + 1] == "'":
index += 1
else:
quote = None
elif char in {'"', "'"}:
quote = char
elif char == "#" and (index == 0 or content[index - 1].isspace()):
return content[:index]
index += 1
if quote is not None:
raise YamlSubsetError("未结束的引号标量")
return content
def _reject_document_syntax(self, content: str, line: _Line) -> None:
if content in {"---", "..."} or content.startswith("%"):
self._error(line, "ACK YAML 子集只支持单文档,不支持 directive/marker")
if content.startswith(("!", "&", "*")):
self._error(line, "ACK YAML 子集不支持 tag/anchor/alias")
@staticmethod
def _error(line: _Line, message: str) -> None:
raise YamlSubsetError(f"{line.number} 行: {message}")
class _FlowParser:
def __init__(self, text: str, line_number: int) -> None:
self.text = text
self.line_number = line_number
self.index = 0
def parse_complete_value(self) -> Any:
value = self._parse_value()
self._skip_space()
if self.index != len(self.text):
self._error(f"标量后存在未支持内容: {self.text[self.index:]!r}")
return value
def _parse_value(self) -> Any:
self._skip_space()
if self.index >= len(self.text):
self._error("缺少标量")
char = self.text[self.index]
if char == "[":
return self._parse_list()
if char == "{":
return self._parse_map()
if char in {'"', "'"}:
return self._parse_quoted()
if char in "]},":
self._error(f"意外字符 {char!r}")
return self._parse_plain({",", "]", "}"})
def _parse_list(self) -> list[Any]:
self.index += 1
result: list[Any] = []
self._skip_space()
if self._consume("]"):
return result
while True:
result.append(self._parse_value())
self._skip_space()
if self._consume("]"):
return result
if not self._consume(","):
self._error("flow list 项之间必须用 ',' 分隔")
self._skip_space()
if self.index >= len(self.text) or self.text[self.index] == "]":
self._error("flow list 不支持尾随逗号")
def _parse_map(self) -> dict[str, Any]:
self.index += 1
result: dict[str, Any] = {}
self._skip_space()
if self._consume("}"):
return result
while True:
self._skip_space()
if self.index >= len(self.text):
self._error("flow mapping 未结束")
if self.text[self.index] in {'"', "'"}:
key = self._parse_quoted()
else:
key = self._parse_plain({":"}, convert=False)
if not isinstance(key, str) or not key:
self._error("flow mapping key 必须是非空字符串")
if key == "<<":
self._error("ACK YAML 子集不支持 merge key '<<'")
self._skip_space()
if not self._consume(":"):
self._error("flow mapping key 后必须是 ':'")
value = self._parse_value()
if key in result:
self._error(f"flow mapping 存在重复键 {key!r}")
result[key] = value
self._skip_space()
if self._consume("}"):
return result
if not self._consume(","):
self._error("flow mapping 项之间必须用 ',' 分隔")
self._skip_space()
if self.index >= len(self.text) or self.text[self.index] == "}":
self._error("flow mapping 不支持尾随逗号")
def _parse_quoted(self) -> str:
quote = self.text[self.index]
self.index += 1
result: list[str] = []
while self.index < len(self.text):
char = self.text[self.index]
self.index += 1
if char == quote:
if quote == "'" and self.index < len(self.text) and self.text[
self.index
] == "'":
result.append("'")
self.index += 1
continue
return "".join(result)
if quote == "'" or char != "\\":
result.append(char)
continue
if self.index >= len(self.text):
self._error("双引号标量以转义符结尾")
escape = self.text[self.index]
self.index += 1
simple = {
"0": "\0",
"a": "\a",
"b": "\b",
"t": "\t",
"n": "\n",
"v": "\v",
"f": "\f",
"r": "\r",
"e": "\x1b",
" ": " ",
'"': '"',
"/": "/",
"\\": "\\",
}
if escape in simple:
result.append(simple[escape])
continue
widths = {"x": 2, "u": 4, "U": 8}
if escape in widths:
width = widths[escape]
digits = self.text[self.index : self.index + width]
if len(digits) != width or not re.fullmatch(
rf"[0-9a-fA-F]{{{width}}}", digits
):
self._error(f"无效 Unicode 转义 \\{escape}{digits}")
codepoint = int(digits, 16)
try:
result.append(chr(codepoint))
except ValueError as exc:
raise YamlSubsetError(
f"{self.line_number} 行: 无效 Unicode 码点"
) from exc
self.index += width
continue
self._error(f"不支持的双引号转义 \\{escape}")
self._error("引号标量未结束")
def _parse_plain(
self,
delimiters: set[str],
*,
convert: bool = True,
) -> Any:
start = self.index
while self.index < len(self.text) and self.text[self.index] not in delimiters:
self.index += 1
token = self.text[start : self.index].strip()
if not token:
self._error("空 plain scalar")
if "#" in token:
self._error("flow collection 内的注释不受支持")
if ": " in token:
self._error("plain scalar 中的 ': ' 必须加引号")
if token[0] in "!&*%@`?" or _ANCHOR_OR_ALIAS_RE.search(token):
self._error("ACK YAML 子集不支持 tag/anchor/alias/directive")
return _plain_scalar(token, self.line_number) if convert else token
def _skip_space(self) -> None:
while self.index < len(self.text) and self.text[self.index] == " ":
self.index += 1
def _consume(self, expected: str) -> bool:
if self.index < len(self.text) and self.text[self.index] == expected:
self.index += 1
return True
return False
def _error(self, message: str) -> None:
raise YamlSubsetError(f"{self.line_number} 行: {message}")
def _plain_scalar(token: str, line_number: int) -> Any:
lowered = token.lower()
if lowered in {"null", "~"}:
return None
if lowered in {"true", "yes", "on"}:
return True
if lowered in {"false", "no", "off"}:
return False
if _DECIMAL_INT_RE.fullmatch(token):
return int(token, 10)
if _AMBIGUOUS_NUMBER_RE.fullmatch(token):
raise YamlSubsetError(
f"{line_number} 行: ACK YAML 子集不支持该数字格式 {token!r}"
"如需字符串请加引号"
)
return token