255 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""play-loop 契约校验器PlaySpec C5 / VerdictFeedback C6
零依赖:只用 Python 标准库json/re/sys/pathlib。之所以不引 jsonschema/ajv——本仓
没装 jsonschema、也没 vendor ajv而 W-ARCH① 红线③要求零新依赖;且两份契约的消费方
cheap-worker gate_judge.py、tier2 run.py本就是 Python落一份 stdlib 校验器与消费侧同栈。
本文件自带一个 JSON-Schema Draft-2020-12 子集校验器,只实现两份 schema 真正用到的关键字:
type / required / properties / patternProperties / additionalProperties /
enum / const / pattern / minimum / maximum / minItems / maxItems / minLength / maxLength /
items / anyOf / oneOf / allOf / if-then-else / $ref(仅 #/$defs/<name> 本地引用)。
关键字集是刻意收窄的schema 由本任务作者,只用上述关键字,正负样本套件即本校验器的回归证明——
若某关键字实现错了,要么正样本会被误拦、要么负样本会漏过,跑套件即暴露。
用法:
1) 单点校验python3 validate.py <schema.json> <sample.json> [<sample2.json> ...]
逐样本打印 PASS/FAIL 与错误路径;全部合规 exit 0否则 exit 1。
2) 套件校验python3 validate.py --suite [samples 根目录,默认脚本旁 samples/]
走 samples/<contract>/valid应全 PASS与 samples/<contract>/invalid应全 FAIL
<contract> 目录名对应同名 schemaplay-spec → play-spec.schema.json
打印每契约『正样本 N 过 / 负样本 M 拦』与总计;期望全部满足 exit 0否则 exit 1。
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
# ── JSON 类型判定(区分 integer/number/booleanPython 里 bool 是 int 子类,须显式排除)──
def _json_type_ok(value, t: str) -> bool:
if t == "object":
return isinstance(value, dict)
if t == "array":
return isinstance(value, list)
if t == "string":
return isinstance(value, str)
if t == "boolean":
return isinstance(value, bool)
if t == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if t == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if t == "null":
return value is None
return False
def _resolve_ref(ref: str, root: dict):
"""解析本地 $ref仅支持 #/$defs/<name> 形态;其余抛错,防静默漏校验)。"""
if not ref.startswith("#/"):
raise ValueError(f"仅支持本地 #/$defs 引用,收到:{ref}")
node = root
for part in ref[2:].split("/"):
# JSON Pointer 转义还原(~1→/~0→~),本仓 key 无特殊字符,兜个底而已。
part = part.replace("~1", "/").replace("~0", "~")
if not isinstance(node, dict) or part not in node:
raise ValueError(f"$ref 指向不存在的节点:{ref}")
node = node[part]
return node
def validate(schema, instance, root: dict, path: str = "#") -> list:
"""按 schema 校验 instance返回错误串列表空=合规。schema 为 dict 或 bool。"""
# 布尔 schematrue=恒过false=恒否(本仓未用,兜底)。
if isinstance(schema, bool):
return [] if schema else [f"{path}: 布尔 schema false恒不合规"]
if not isinstance(schema, dict):
return [f"{path}: schema 非法(既非对象也非布尔)"]
errors: list = []
# $ref解析后就地校验本仓 $ref 节点自带完整约束,直接接管本层)。
if "$ref" in schema:
target = _resolve_ref(schema["$ref"], root)
return validate(target, instance, root, path)
# type
if "type" in schema:
types = schema["type"]
types = types if isinstance(types, list) else [types]
if not any(_json_type_ok(instance, t) for t in types):
errors.append(f"{path}: 类型应为 {types},实际 {type(instance).__name__}")
# const / enum
if "const" in schema and instance != schema["const"]:
errors.append(f"{path}: 应恒等于 {schema['const']!r},实际 {instance!r}")
if "enum" in schema and instance not in schema["enum"]:
errors.append(f"{path}: 应属枚举 {schema['enum']},实际 {instance!r}")
# 字符串约束
if isinstance(instance, str):
if "minLength" in schema and len(instance) < schema["minLength"]:
errors.append(f"{path}: 字符串长度应 ≥ {schema['minLength']},实际 {len(instance)}")
if "maxLength" in schema and len(instance) > schema["maxLength"]:
errors.append(f"{path}: 字符串长度应 ≤ {schema['maxLength']},实际 {len(instance)}")
if "pattern" in schema and re.search(schema["pattern"], instance) is None:
errors.append(f"{path}: 应匹配正则 /{schema['pattern']}/,实际 {instance!r}")
# 数值约束(排除 bool
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
if "minimum" in schema and instance < schema["minimum"]:
errors.append(f"{path}: 应 ≥ {schema['minimum']},实际 {instance}")
if "maximum" in schema and instance > schema["maximum"]:
errors.append(f"{path}: 应 ≤ {schema['maximum']},实际 {instance}")
# 数组约束
if isinstance(instance, list):
if "minItems" in schema and len(instance) < schema["minItems"]:
errors.append(f"{path}: 数组元素数应 ≥ {schema['minItems']},实际 {len(instance)}")
if "maxItems" in schema and len(instance) > schema["maxItems"]:
errors.append(f"{path}: 数组元素数应 ≤ {schema['maxItems']},实际 {len(instance)}")
if "items" in schema:
for i, item in enumerate(instance):
errors += validate(schema["items"], item, root, f"{path}/{i}")
# 对象约束
if isinstance(instance, dict):
for key in schema.get("required", []):
if key not in instance:
errors.append(f"{path}: 缺必填字段 '{key}'")
props = schema.get("properties", {})
pattern_props = schema.get("patternProperties", {})
add_props = schema.get("additionalProperties", True)
for key, val in instance.items():
handled = False
if key in props:
errors += validate(props[key], val, root, f"{path}/{key}")
handled = True
for pat, sub in pattern_props.items():
if re.search(pat, key) is not None:
errors += validate(sub, val, root, f"{path}/{key}")
handled = True
if not handled:
if add_props is False:
errors.append(f"{path}: 不允许的额外字段 '{key}'additionalProperties=false")
elif isinstance(add_props, dict):
errors += validate(add_props, val, root, f"{path}/{key}")
# 组合子allOf / anyOf / oneOf
for i, sub in enumerate(schema.get("allOf", [])):
errors += validate(sub, instance, root, f"{path}/allOf[{i}]")
if "anyOf" in schema:
branch_errs = [validate(sub, instance, root, f"{path}/anyOf[{i}]")
for i, sub in enumerate(schema["anyOf"])]
if not any(len(e) == 0 for e in branch_errs):
errors.append(f"{path}: anyOf 无一分支匹配(各分支首错:" +
"".join(e[0] for e in branch_errs if e) + "")
if "oneOf" in schema:
matched = sum(1 for sub in schema["oneOf"]
if len(validate(sub, instance, root, path)) == 0)
if matched != 1:
errors.append(f"{path}: oneOf 应恰好 1 分支匹配,实际 {matched}")
# 条件子if-then-elseif 本身不产错,命中则套 then、否则套 else
if "if" in schema:
if len(validate(schema["if"], instance, root, path)) == 0:
if "then" in schema:
errors += validate(schema["then"], instance, root, f"{path}/then")
else:
if "else" in schema:
errors += validate(schema["else"], instance, root, f"{path}/else")
return errors
def _load(p: Path):
return json.loads(p.read_text(encoding="utf-8"))
def _validate_file(schema: dict, sample_path: Path) -> list:
"""校验单个样本文件返回错误列表JSON 解析失败也算一条错误)。"""
try:
instance = _load(sample_path)
except Exception as e: # noqa: BLE001 —— 解析失败即样本不合格,如实报,不抛断链
return [f"#: JSON 解析失败:{e}"]
return validate(schema, instance, schema)
def _run_suite(samples_root: Path) -> int:
"""套件模式正样本应全过、负样本应全拦。返回进程退出码0=期望全满足)。"""
contracts = [
("play-spec", _HERE / "play-spec.schema.json"),
("verdict-feedback", _HERE / "verdict-feedback.schema.json"),
]
total_pass = total_block = 0
all_ok = True
for name, schema_path in contracts:
schema = _load(schema_path)
base = samples_root / name
valid_dir, invalid_dir = base / "valid", base / "invalid"
valids = sorted(valid_dir.glob("*.json")) if valid_dir.is_dir() else []
invalids = sorted(invalid_dir.glob("*.json")) if invalid_dir.is_dir() else []
print(f"\n══ {name}{schema_path.name})══")
n_pass = 0
for f in valids:
errs = _validate_file(schema, f)
if errs:
all_ok = False
print(f" ✗ 正样本本应过却被拦:{f.name}")
for e in errs[:4]:
print(f" {e}")
else:
n_pass += 1
print(f" ✓ 正样本过:{f.name}")
n_block = 0
for f in invalids:
errs = _validate_file(schema, f)
if errs:
n_block += 1
# 打印首条错误,证明是按预期规则拦下的(非偶然)。
print(f" ✓ 负样本拦:{f.name} —— {errs[0]}")
else:
all_ok = False
print(f" ✗ 负样本本应拦却放过:{f.name}")
print(f" 小计:正样本 {n_pass}/{len(valids)} 过,负样本 {n_block}/{len(invalids)}")
total_pass += n_pass
total_block += n_block
print(f"\n══ 总计:正样本 {total_pass} 过 / 负样本 {total_block} 拦 —— "
f"{'全部符合预期' if all_ok else '存在未达预期项(见上 ✗)'}")
return 0 if all_ok else 1
def main(argv: list) -> int:
if len(argv) >= 2 and argv[1] == "--suite":
root = Path(argv[2]).resolve() if len(argv) >= 3 else (_HERE / "samples")
return _run_suite(root)
if len(argv) < 3:
print(__doc__)
return 2
schema = _load(Path(argv[1]).resolve())
exit_code = 0
for sample_arg in argv[2:]:
sample_path = Path(sample_arg).resolve()
errs = _validate_file(schema, sample_path)
if errs:
exit_code = 1
print(f"FAIL {sample_path.name}")
for e in errs:
print(f" {e}")
else:
print(f"PASS {sample_path.name}")
return exit_code
if __name__ == "__main__":
sys.exit(main(sys.argv))