← Files Email LoveARCHIVED FILE
skills/email-love-figma-quality-gates/scripts/validate_batch_snapshot.py
29.5 KB · Oct 4, 2026 · 12:29 UTC
#!/usr/bin/env python3
"""Validate a machine-readable Email Love Figma QA snapshot.
Fail-closed contract: a measurement that is missing, mistyped, negative, or
non-finite is a validation error, never a silent zero. Passing here is
SNAPSHOT validation only; production acceptance still requires the recorded
desktop and mobile render evidence.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
ALLOWED_STATES = {
"complete",
"canvas and structure ready, exporter verification deferred",
"batch rejected, repair required",
"audit incomplete, missing source authority",
}
ALLOWED_RISKS = {
"photo-crop",
"grouped-icon-text",
"multi-column-properties",
"footer-social",
}
PARITY_KEYS = {
"content",
"order",
"crop",
"type",
"color",
"spacing",
"desktop",
"mobileIntent",
}
# Documented render-contract exceptions a snapshot may cite for an unequal
# auto-layout axis pair. Anything else counts as an unintended mismatch.
ALLOWED_AXIS_REASONS = {"top-aligned-multi-column"}
NOTE = ("Snapshot validation only; production acceptance still requires the "
"recorded desktop and mobile render evidence.")
@dataclass(frozen=True)
class Issue:
severity: str
code: str
location: str
message: str
def is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def validate(snapshot: dict[str, Any]) -> list[Issue]:
issues: list[Issue] = []
def add(severity: str, code: str, location: str, message: str) -> None:
issues.append(Issue(severity, code, location, message))
def measured(container: dict[str, Any], field: str, location: str,
minimum: float | None = 0.0) -> float | None:
"""A required numeric measurement. Missing or malformed fails closed."""
value = container.get(field)
if not is_number(value):
add("error", "UNKNOWN_MEASUREMENT", f"{location}.{field}",
f"Measurement is missing or not a finite number: {value!r}.")
return None
if minimum is not None and value < minimum:
add("error", "MEASUREMENT_NEGATIVE", f"{location}.{field}",
f"Measurement must be at least {minimum:g}, found {value:g}.")
return None
return float(value)
def string_list(container: dict[str, Any], field: str, location: str) -> list[str] | None:
value = container.get(field)
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
add("error", "LIST_TYPE_INVALID", f"{location}.{field}",
"Expected an array of strings.")
return None
return value
batch = snapshot.get("batch")
modules = snapshot.get("modules")
if not isinstance(batch, dict):
add("error", "BATCH_MISSING", "batch", "Expected a batch object.")
batch = {}
if not isinstance(modules, list):
add("error", "MODULES_MISSING", "modules", "Expected a modules array.")
modules = []
phase = batch.get("phase")
if phase not in {"proof", "normal"}:
add("error", "PHASE_INVALID", "batch.phase", "Use 'proof' or 'normal'.")
if phase in {"proof", "normal"} and len(modules) == 0:
add("error", "MODULES_EMPTY", "modules",
"A migration acceptance batch needs at least one measured module; "
"an empty list is absent evidence, not a pass.")
if phase == "proof" and len(modules) > 4:
add("error", "PROOF_TOO_LARGE", "modules", "A proof batch may contain at most four modules.")
if phase == "normal" and batch.get("proofBatchAccepted") is not True:
add("error", "PROOF_NOT_ACCEPTED", "batch.proofBatchAccepted", "A normal batch requires an accepted proof batch.")
if phase == "proof":
present = string_list(batch, "riskClassesPresent", "batch")
covered = string_list(batch, "proofRisksCovered", "batch")
if present is not None and covered is not None:
unknown = (set(present) | set(covered)) - ALLOWED_RISKS
if unknown:
add("error", "PROOF_RISK_INVALID", "batch", f"Unknown risk classes: {sorted(unknown)}")
missing = set(present) - set(covered)
if missing:
add("error", "PROOF_RISK_UNCOVERED", "batch.proofRisksCovered", f"Proof batch does not cover: {sorted(missing)}")
viewports = batch.get("mobileViewports")
if not isinstance(viewports, list) or not viewports or not all(is_number(v) and v > 0 for v in viewports):
add("error", "VIEWPORTS_INVALID", "batch.mobileViewports", "Provide one or more positive mobile viewport widths.")
viewports = []
state = batch.get("statusClaim")
if state not in ALLOWED_STATES:
add("error", "STATUS_INVALID", "batch.statusClaim", "Use one of the quality-gate completion states.")
exporter = batch.get("exporter") if isinstance(batch.get("exporter"), dict) else {}
desktop = exporter.get("desktop")
mobile = exporter.get("mobile")
for channel, value in (("desktop", desktop), ("mobile", mobile)):
if value not in {"pass", "fail", "deferred"}:
add("error", "EXPORT_STATUS_INVALID", f"batch.exporter.{channel}", "Use pass, fail, or deferred.")
if state == "complete" and (desktop != "pass" or mobile != "pass"):
add("error", "COMPLETE_WITHOUT_EXPORT", "batch.statusClaim", "Complete requires desktop and mobile production passes.")
if "fail" in {desktop, mobile} and state != "batch rejected, repair required":
add("error", "FAILED_EXPORT_NOT_REJECTED", "batch.statusClaim", "A failed production render requires batch rejection.")
if state == "canvas and structure ready, exporter verification deferred" and desktop == mobile == "pass":
add("warning", "STALE_DEFERRED_STATUS", "batch.statusClaim", "Both production renders pass; update the status after remaining gates pass.")
for index, module in enumerate(modules):
loc = f"modules[{index}]"
if not isinstance(module, dict):
add("error", "MODULE_INVALID", loc, "Expected an object.")
continue
name = module.get("name") or f"module {index + 1}"
mloc = f"{loc} ({name})"
if not str(module.get("sourceRef") or "").strip():
add("error", "SOURCE_MISSING", f"{mloc}.sourceRef", "Record the exact source authority.")
parity = module.get("sourceParity")
if not isinstance(parity, dict):
add("error", "PARITY_MISSING", f"{mloc}.sourceParity", "Record every source-parity dimension.")
else:
for key in sorted(PARITY_KEYS):
if parity.get(key) is not True:
add("error", "PARITY_FAILED", f"{mloc}.sourceParity.{key}", "Parity is missing or did not pass.")
structure = module.get("structure")
if not isinstance(structure, dict):
add("error", "STRUCTURE_MISSING", f"{mloc}.structure", "Record measured structure results.")
structure = {}
artifact_kind = module.get("artifactKind", "module")
root_tag = structure.get("rootTag", "")
node_type = structure.get("nodeType", "")
if artifact_kind == "module":
if root_tag != "mj-wrapper" or node_type not in {"", None}:
add("error", "MODULE_ROOT_INVALID", f"{mloc}.structure", "A module is an mj-wrapper with no nodeType marker.")
if structure.get("directPageChild") is not True:
add("error", "MODULE_NOT_PAGE_CHILD", f"{mloc}.structure.directPageChild", "A module root must be a direct page child.")
elif artifact_kind == "email":
if root_tag not in {"", None} or node_type != "mainFrame":
add("error", "EMAIL_ROOT_INVALID", f"{mloc}.structure", "An email root has mainFrame and no MJML tag.")
theme_keys = measured(structure, "themeKeyCount", f"{mloc}.structure")
if theme_keys is not None and theme_keys < 9:
add("error", "EMAIL_THEME_KEYS", f"{mloc}.structure.themeKeyCount", "An email root needs all nine root keys.")
if structure.get("directChildrenAllWrappers") is not True:
add("error", "EMAIL_CHILDREN_INVALID", f"{mloc}.structure", "An email root's direct children must all be wrappers.")
else:
add("error", "ARTIFACT_KIND_INVALID", f"{mloc}.artifactKind", "Use module or email.")
for field, code in (
("untaggedFrameCount", "UNTAGGED_FRAMES"),
("unknownTagCount", "UNKNOWN_TAGS"),
("incompleteLeafPairCount", "INCOMPLETE_LEAVES"),
("unintendedFixedHeightCount", "FIXED_HEIGHTS"),
):
value = measured(structure, field, f"{mloc}.structure")
if value is not None and value != 0:
add("error", code, f"{mloc}.structure.{field}", f"Expected 0, found {value:g}.")
# Unequal auto-layout axes: a documented render-contract exception
# (e.g. top-aligned multi-column content) may be cited per node.
# Only UNDOCUMENTED mismatches fail.
unequal_axes = measured(structure, "unequalAxisCount", f"{mloc}.structure")
axis_exceptions = structure.get("axisExceptions", [])
valid_exceptions = 0
if not isinstance(axis_exceptions, list):
add("error", "AXIS_EXCEPTIONS_INVALID", f"{mloc}.structure.axisExceptions", "Expected an array.")
else:
for exception_index, exception in enumerate(axis_exceptions):
eloc = f"{mloc}.structure.axisExceptions[{exception_index}]"
if (not isinstance(exception, dict)
or not str(exception.get("node") or "").strip()
or exception.get("reason") not in ALLOWED_AXIS_REASONS):
add("error", "AXIS_EXCEPTION_INVALID", eloc,
f"Each exception needs a node and a documented reason from {sorted(ALLOWED_AXIS_REASONS)}.")
else:
valid_exceptions += 1
if unequal_axes is not None and unequal_axes > valid_exceptions:
add("error", "UNEQUAL_AXES", f"{mloc}.structure.unequalAxisCount",
f"{unequal_axes:g} unequal axis pair(s) but only {valid_exceptions} documented exception(s); "
"undocumented mismatches are defects.")
# Inventories are REQUIRED, even when audited empty: an omitted array
# is absent evidence and fails closed. The census cross-check keeps an
# empty array from concealing real content.
census = module.get("census")
if not isinstance(census, dict):
add("error", "CENSUS_MISSING", f"{mloc}.census",
"Record the independently measured node census: images, groups, properties counts.")
census = {}
inventories: dict[str, list[Any]] = {}
for field in ("images", "groups", "properties"):
value = module.get(field)
if not isinstance(value, list):
add("error", "INVENTORY_MISSING", f"{mloc}.{field}",
"Inventory array is required, even when audited empty; an omitted array is absent evidence.")
inventories[field] = []
continue
inventories[field] = value
expected = measured(census, field, f"{mloc}.census") if isinstance(census, dict) else None
if expected is not None and int(expected) != len(value):
add("error", "CENSUS_MISMATCH", f"{mloc}.{field}",
f"Census counted {int(expected)} {field} but the snapshot records {len(value)}.")
for image_index, image in enumerate(inventories["images"]):
iloc = f"{mloc}.images[{image_index}]"
if not isinstance(image, dict):
add("error", "IMAGE_INVALID", iloc, "Expected an object.")
continue
meaningful = image.get("meaningful", True)
if image.get("fillType") != "IMAGE":
add("error", "IMAGE_FILL_INVALID", f"{iloc}.fillType", "A meaningful image must use an IMAGE fill.")
if not str(image.get("imageHash") or "").strip():
add("error", "IMAGE_HASH_MISSING", f"{iloc}.imageHash", "Read back a non-empty image hash.")
if meaningful and not str(image.get("altText") or "").strip():
add("error", "ALT_TEXT_MISSING", f"{iloc}.altText", "Meaningful imagery needs alt text.")
rw = measured(image, "rectWidth", iloc)
rh = measured(image, "rectHeight", iloc)
aw = measured(image, "assetWidth", iloc)
ah = measured(image, "assetHeight", iloc)
if None in (rw, rh, aw, ah):
continue
if min(rw, rh, aw, ah) <= 0:
add("error", "IMAGE_GEOMETRY_INVALID", iloc, "Rectangle and asset dimensions must be positive.")
else:
rect_ratio, asset_ratio = rw / rh, aw / ah
ratio_drift = abs(rect_ratio - asset_ratio) / asset_ratio
if not image.get("cropApproved", False) and ratio_drift > 0.02:
add("error", "IMAGE_ASPECT_MISMATCH", iloc, f"Rectangle/asset aspect ratios drift by {ratio_drift:.1%} without an approved crop.")
if image.get("role") in {"icon", "social"} and not image.get("nonSquareApproved", False):
if abs(rect_ratio - 1.0) > 0.02:
add("error", "ICON_NOT_SQUARE", iloc, "Icon rectangle is not square and has no exception.")
for group_index, group in enumerate(inventories["groups"]):
gloc = f"{mloc}.groups[{group_index}]"
if not isinstance(group, dict):
add("error", "GROUP_INVALID", gloc, "Expected an object.")
continue
group_width = measured(group, "width", gloc)
columns = group.get("columns")
if group_width is None or group_width <= 0 or not isinstance(columns, list) or len(columns) < 2:
add("error", "GROUP_GEOMETRY_INVALID", gloc, "A group needs a positive width and at least two columns.")
continue
column_widths: list[float] = []
columns_ok = True
for column_index, column in enumerate(columns):
if not isinstance(column, dict):
add("error", "COLUMN_INVALID", f"{gloc}.columns[{column_index}]", "Expected an object.")
columns_ok = False
continue
width = measured(column, "width", f"{gloc}.columns[{column_index}]")
if width is None:
columns_ok = False
else:
column_widths.append(width)
# A fixed bordered group may reserve documented headroom (per the
# render contract): columns then sum SHORT of the outer width by
# exactly the declared amount. An unexplained gap is a defect.
headroom = 0.0
if "borderHeadroom" in group:
declared = measured(group, "borderHeadroom", gloc)
if declared is not None:
headroom = declared
if headroom > 0 and not str(group.get("headroomReason") or "").strip():
add("error", "HEADROOM_UNDOCUMENTED", f"{gloc}.headroomReason",
"Declared border headroom needs a stated render-contract reason.")
if columns_ok:
column_sum = sum(column_widths)
if abs(column_sum + headroom - group_width) > 0.5:
add("error", "GROUP_WIDTH_SUM", gloc,
f"Column widths total {column_sum:g} plus declared headroom {headroom:g} "
f"does not match group width {group_width:g}.")
section_left = measured(group, "mobileSectionPaddingLeft", gloc)
section_right = measured(group, "mobileSectionPaddingRight", gloc)
if section_left is None or section_right is None:
continue
for viewport in viewports:
mobile_content = float(viewport) - section_left - section_right
if mobile_content <= 0:
add("error", "MOBILE_CONTENT_INVALID", gloc, f"Viewport {viewport:g}px has no positive content box.")
continue
for column_index, column in enumerate(columns):
cloc = f"{gloc}.columns[{column_index}]@{viewport:g}px"
if not isinstance(column, dict):
continue
column_width = measured(column, "width", cloc)
pad_left = measured(column, "paddingLeft", cloc)
pad_right = measured(column, "paddingRight", cloc)
if None in (column_width, pad_left, pad_right):
continue
resolved = column_width / group_width * mobile_content
inner = resolved - pad_left - pad_right
if inner <= 0:
add("error", "MOBILE_INNER_INVALID", cloc, f"Resolved inner width is {inner:.2f}px.")
continue
content = column.get("content") if isinstance(column.get("content"), dict) else {}
kind = content.get("kind")
if kind == "image":
required = measured(content, "naturalWidth", cloc)
if required is None or required <= 0:
add("error", "IMAGE_NATURAL_WIDTH_MISSING", cloc, "Record the image's minimum natural display width.")
elif inner + 0.01 < required:
add("error", "MOBILE_IMAGE_TOO_NARROW", cloc, f"Inner width {inner:.2f}px is below image requirement {required:.2f}px.")
elif kind == "text":
required = measured(content, "longestUnbreakablePx", cloc)
if required is None or required <= 0:
add("error", "TEXT_WIDTH_MISSING", cloc, "Record the exported-font unbreakable width.")
elif inner + 0.01 < required:
add("error", "MOBILE_TEXT_TOO_NARROW", cloc, f"Inner width {inner:.2f}px is below text requirement {required:.2f}px.")
else:
add("warning", "GROUP_CONTENT_UNMEASURED", cloc, "Content kind is not image or text; disposition manually.")
for property_index, prop in enumerate(inventories["properties"]):
ploc = f"{mloc}.properties[{property_index}]"
if not isinstance(prop, dict):
add("error", "PROPERTY_INVALID", ploc, "Expected an object.")
continue
prop_type = prop.get("type")
if prop_type not in {"TEXT", "BOOLEAN", "INSTANCE_SWAP", "VARIANT"}:
add("error", "PROPERTY_TYPE_INVALID", f"{ploc}.type", "Use a Figma component-property type.")
if not str(prop.get("evidence") or "").strip():
add("error", "PROPERTY_EVIDENCE_MISSING", f"{ploc}.evidence", "Every property needs a stated use case.")
binding_count = measured(prop, "bindingCount", ploc)
if binding_count is not None and binding_count < 1:
add("error", "PROPERTY_UNBOUND", f"{ploc}.bindingCount", "Read back at least one binding.")
if prop_type == "BOOLEAN":
if prop.get("hideScope") != "complete-region":
add("error", "BOOLEAN_SCOPE_INCOMPLETE", f"{ploc}.hideScope", "Bind the complete optional semantic region.")
if prop.get("remainingLayoutComplete") is not True:
add("error", "BOOLEAN_FALSE_STATE_BROKEN", ploc, "The false state must leave a finished layout.")
if prop.get("ancestorRequiredFixedColumn") is True:
add("error", "BOOLEAN_EMPTY_COLUMN_RISK", ploc, "The binding sits inside a required fixed column.")
if prop_type == "TEXT":
if prop.get("boilerplate") is True:
add("error", "TEXT_PROPERTY_BOILERPLATE", ploc, "Do not expose standing boilerplate as campaign copy.")
if prop.get("linkBearing") is True:
add("error", "TEXT_PROPERTY_LINK_RISK", ploc, "Replacing this text can destroy its hyperlink.")
return issues
def good_fixture() -> dict[str, Any]:
return {
"batch": {
"name": "proof",
"phase": "proof",
"emailWidth": 600,
"contentWidth": 520,
"mobileViewports": [320, 375, 390],
"riskClassesPresent": ["grouped-icon-text"],
"proofRisksCovered": ["grouped-icon-text"],
"proofBatchAccepted": True,
"statusClaim": "complete",
"exporter": {"desktop": "pass", "mobile": "pass"},
},
"modules": [{
"id": "1:1",
"name": "Safe icon row",
"artifactKind": "module",
"sourceRef": "source frame 1:2",
"sourceParity": {key: True for key in PARITY_KEYS},
"structure": {
"rootTag": "mj-wrapper",
"nodeType": "",
"directPageChild": True,
"untaggedFrameCount": 0,
"unknownTagCount": 0,
"incompleteLeafPairCount": 0,
"unequalAxisCount": 0,
"axisExceptions": [],
"unintendedFixedHeightCount": 0,
},
"census": {"images": 1, "groups": 1, "properties": 1},
"images": [{
"name": "sparkle",
"role": "icon",
"meaningful": True,
"fillType": "IMAGE",
"imageHash": "hash",
"scaleMode": "FILL",
"rectWidth": 32,
"rectHeight": 32,
"assetWidth": 64,
"assetHeight": 64,
"cropApproved": False,
"altText": "Odor filtration",
}],
"groups": [{
"name": "feature row",
"width": 520,
"mobileSectionPaddingLeft": 40,
"mobileSectionPaddingRight": 40,
"columns": [
{"name": "icon", "width": 96, "paddingLeft": 0, "paddingRight": 10,
"content": {"kind": "image", "naturalWidth": 32}},
{"name": "text", "width": 424, "paddingLeft": 16, "paddingRight": 0,
"content": {"kind": "text", "longestUnbreakablePx": 90}},
],
}],
"properties": [{
"name": "Show feature row",
"type": "BOOLEAN",
"evidence": "Source family contains a shorter version",
"bindingCount": 1,
"hideScope": "complete-region",
"remainingLayoutComplete": True,
"ancestorRequiredFixedColumn": False,
}],
}],
}
def error_codes(snapshot: dict[str, Any]) -> set[str]:
return {issue.code for issue in validate(snapshot) if issue.severity == "error"}
def self_test() -> int:
failures: list[str] = []
def expect(label: str, condition: bool) -> None:
if not condition:
failures.append(label)
good_issues = validate(good_fixture())
expect("good fixture passes", not any(issue.severity == "error" for issue in good_issues))
bad = good_fixture()
bad["batch"]["statusClaim"] = "complete"
bad["batch"]["exporter"]["mobile"] = "deferred"
module = bad["modules"][0]
module["images"][0]["fillType"] = "SOLID"
module["groups"][0]["columns"][0]["width"] = 72
module["groups"][0]["columns"][1]["width"] = 448
module["properties"][0]["hideScope"] = "leaf-only"
module["properties"][0]["remainingLayoutComplete"] = False
module["properties"][0]["ancestorRequiredFixedColumn"] = True
expect("classic regression fixture fails with the expected codes", {
"COMPLETE_WITHOUT_EXPORT",
"IMAGE_FILL_INVALID",
"MOBILE_IMAGE_TOO_NARROW",
"BOOLEAN_SCOPE_INCOMPLETE",
"BOOLEAN_FALSE_STATE_BROKEN",
"BOOLEAN_EMPTY_COLUMN_RISK",
}.issubset(error_codes(bad)))
# Fail-closed regressions (2026-08-30 review, F2).
empty = good_fixture()
empty["modules"] = []
expect("empty module list on a complete proof batch fails",
"MODULES_EMPTY" in error_codes(empty))
omitted = good_fixture()
for field in ("images", "groups", "properties"):
del omitted["modules"][0][field]
codes = error_codes(omitted)
expect("omitted inventories fail rather than defaulting empty",
"INVENTORY_MISSING" in codes)
concealed = good_fixture()
concealed["modules"][0]["images"] = []
expect("empty array contradicting the census fails",
"CENSUS_MISMATCH" in error_codes(concealed))
no_census = good_fixture()
del no_census["modules"][0]["census"]
expect("missing census fails", "CENSUS_MISSING" in error_codes(no_census))
unknown_pad = good_fixture()
unknown_pad["modules"][0]["groups"][0]["columns"][0]["width"] = 72
unknown_pad["modules"][0]["groups"][0]["columns"][1]["width"] = 448
unknown_pad["modules"][0]["groups"][0]["columns"][0]["paddingRight"] = "unknown"
expect("unknown padding fails instead of becoming zero",
"UNKNOWN_MEASUREMENT" in error_codes(unknown_pad))
malformed_risk = good_fixture()
malformed_risk["batch"]["riskClassesPresent"] = [{"class": "grouped-icon-text"}]
try:
expect("object in risk list is a structured error",
"LIST_TYPE_INVALID" in error_codes(malformed_risk))
except TypeError:
failures.append("object in risk list raised instead of validating")
# Documented render-contract exceptions (2026-08-30 review, F3).
top_aligned = good_fixture()
top_aligned["modules"][0]["structure"]["unequalAxisCount"] = 1
top_aligned["modules"][0]["structure"]["axisExceptions"] = [
{"node": "3:7 benefits columns", "reason": "top-aligned-multi-column"}]
expect("documented top-aligned multi-column exception passes",
"UNEQUAL_AXES" not in error_codes(top_aligned))
undocumented_axis = good_fixture()
undocumented_axis["modules"][0]["structure"]["unequalAxisCount"] = 1
expect("undocumented unequal axis fails",
"UNEQUAL_AXES" in error_codes(undocumented_axis))
bordered = good_fixture()
bordered["modules"][0]["groups"][0]["width"] = 522
bordered["modules"][0]["groups"][0]["borderHeadroom"] = 2
bordered["modules"][0]["groups"][0]["headroomReason"] = "fixed bordered group reserves 1px per side"
expect("documented border headroom passes",
"GROUP_WIDTH_SUM" not in error_codes(bordered))
gap = good_fixture()
gap["modules"][0]["groups"][0]["width"] = 522
expect("unexplained width gap fails", "GROUP_WIDTH_SUM" in error_codes(gap))
# The documented example must validate exactly as written, so the schema
# reference cannot drift from the validator (2026-08-30 review, F2).
schema_doc = Path(__file__).resolve().parent.parent / "references" / "snapshot-schema.md"
expect("snapshot-schema.md is packaged beside the scripts", schema_doc.is_file())
if schema_doc.is_file():
text = schema_doc.read_text()
try:
fence = text.split("```json", 1)[1].split("```", 1)[0]
example = json.loads(fence)
except (IndexError, json.JSONDecodeError):
failures.append("snapshot-schema.md example is not parseable JSON")
else:
example_errors = [issue for issue in validate(example) if issue.severity == "error"]
if example_errors:
for issue in example_errors:
print(f" doc example: {issue.code} at {issue.location}", file=sys.stderr)
expect("documented schema example validates unchanged", not example_errors)
if failures:
for failure in failures:
print(f"self-test failed: {failure}", file=sys.stderr)
return 1
print("validate_batch_snapshot.py self-test passed")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("snapshot", nargs="?", type=Path)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
if args.self_test:
return self_test()
if args.snapshot is None:
parser.error("snapshot is required unless --self-test is used")
try:
snapshot = json.loads(args.snapshot.read_text())
except (OSError, json.JSONDecodeError) as exc:
print(json.dumps({"passed": False, "note": NOTE, "error": str(exc)}, indent=2))
return 1
if not isinstance(snapshot, dict):
print(json.dumps({"passed": False, "note": NOTE, "error": "Top-level JSON must be an object."}, indent=2))
return 1
try:
issues = validate(snapshot)
except Exception as exc: # malformed input must yield a structured error, not a traceback
print(json.dumps({"passed": False, "note": NOTE,
"error": f"Snapshot could not be validated: {type(exc).__name__}: {exc}"}, indent=2))
return 1
errors = sum(issue.severity == "error" for issue in issues)
warnings = sum(issue.severity == "warning" for issue in issues)
print(json.dumps({
"passed": errors == 0,
"note": NOTE,
"errors": errors,
"warnings": warnings,
"issues": [asdict(issue) for issue in issues],
}, indent=2))
return 0 if errors == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: f74352f7d8b10bed7e5072bc973461a90054dd3d12a5bdb7b69e225b89fb18b7