feat(ack): add project knowledge guardrails
This commit is contained in:
Executable
+430
@@ -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())
|
||||
Executable
+275
@@ -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())
|
||||
Executable
+1152
File diff suppressed because it is too large
Load Diff
@@ -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 才能解析 YAML:pip 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")
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ACK YAML 的零依赖、fail-closed 子集解析器。
|
||||
|
||||
这不是通用 YAML 实现。它只覆盖 ACK 状态文件所需的 mapping、
|
||||
sequence、flow collection、标量和 ``>`` / ``|`` block scalar。锚点、
|
||||
alias、tag、多文档和其它未实现语法会显式失败,不会猜测或静默误解析。
|
||||
"""
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user