open-standards/joule-ui/conformance/lang_verify.py
Transaction Science 8a0f6073f7 joule-ui: the line language (SPEC §11), a streaming ingest into validated widget trees
joule-ui takes in OpenUI Lang 1.0 the way it takes in A2UI: a model writes a
surface as a program, one widget a line, and joule-ui-lang builds a widget tree
against the host's registry as the program streams. After every chunk a snapshot
holds the tree as far as it can be built, each widget checked by joule-ui's own
validate, and every error with its source line and reason code. State, queries,
operators, conditionals, built-in functions and sequences of actions are parsed
and refused not-declarative. A canonical writer turns a tree back into the
language; it parses back to the same tree.

- joule-ui-core: WidgetSchema::positional, the order a positional call passes
  props in (SPEC §3); absent from the wire form when undeclared.
- joule-ui-interaction: begin_interaction and Pending::finish, the lifecycle's
  three phases in two steps for a host whose handler is asynchronous (SPEC §6);
  run_interaction is built on them and seals what it sealed before.
- conformance/lang/v1: L1 statements, L2 ingest, L3 streaming, L4 the canonical
  form, L5 refusals, with an independent Python verifier written from §11.
- docs/create.md: the design of Create.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:46:19 -04:00

1207 lines
42 KiB
Python

#!/usr/bin/env python3
"""lang_verify.py: the independent verifier of joule-ui's line language (SPEC §11).
Written from SPEC.md §4 and §11 alone, sharing no code with the reference
implementation (joule-ui-rs/crates/joule-ui-lang). It recomputes every case of the
pack in conformance/lang/v1/ (L1 statements, L2 ingest, L3 streaming, L4 the
canonical form, L5 refusals) from the case's input and the pack's library, and
compares what it gets with what the pack states. Errors are compared without
their messages, which are not normative.
python3 -I lang_verify.py [<pack dir>] verify the pack
python3 -I lang_verify.py --mutations [<dir>] show that changed vectors are refused
Exit status: 0 when every case agrees (or, with --mutations, when every mutation is
caught); 1 otherwise. Standard library only.
"""
from decimal import Decimal
import hashlib
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PACK = os.path.join(HERE, "lang", "v1")
# ---------------------------------------------------------------------------
# §4 validation
def ptype(v):
return v["type"]
def validate(w, reg, path="$"):
"""SPEC §4: every error of the tree, pre-order."""
errs = []
s = reg.get(w["name"])
if s is None:
errs.append({"kind": "unknown_widget", "name": w["name"], "path": path})
return errs
props = w.get("props", {})
for p in sorted(s["props"]):
if s["props"][p].get("required") and p not in props:
errs.append({"kind": "missing_prop", "prop": p, "path": path})
for p in sorted(props):
if p in s["props"]:
want = s["props"][p]["ty"]
if want != "any" and want != ptype(props[p]):
errs.append({"kind": "wrong_prop_type", "prop": p, "path": path})
elif not s.get("allow_extra_props"):
errs.append({"kind": "extra_prop", "prop": p, "path": path})
ch = s["children"]
for i, c in enumerate(w.get("children", [])):
cp = "%s.children[%d]" % (path, i)
kind = ch["kind"]
ok = kind == "any" or (kind == "only_of" and c["name"] in ch["value"])
if not ok:
errs.append({"kind": "disallowed_child", "child": c["name"], "path": cp})
errs.extend(validate(c, reg, cp))
return errs
# ---------------------------------------------------------------------------
# §11.1 source text
MARKER = "]]>openui:"
FENCE = "```"
def strip_comments(t):
out = []
quote = None
i = 0
n = len(t)
while i < n:
c = t[i]
if quote is not None:
out.append(c)
if c == "\\":
if i + 1 < n:
out.append(t[i + 1])
i += 2
continue
elif c == quote:
quote = None
i += 1
continue
if c in "\"'":
quote = c
out.append(c)
i += 1
continue
if c == "#" or (c == "/" and i + 1 < n and t[i + 1] == "/"):
while i < n and t[i] != "\n":
i += 1
continue
out.append(c)
i += 1
return "".join(out)
def prepare(source):
text = source.replace("\r\n", "\n")
lines = [(i + 1, l) for i, l in enumerate(text.split("\n"))]
if any(l.startswith(MARKER) for _, l in lines):
kept = []
content = False
for n, l in lines:
if l.startswith(MARKER):
content = l[len(MARKER):].startswith("content")
continue
if content:
kept.append((n, l))
lines = kept
if any(l.startswith(FENCE) for _, l in lines):
kept = []
inside = False
dq = False
skip = False
for n, l in lines:
if not inside:
if l.startswith(FENCE):
inside, dq, skip = True, False, False
continue
if not dq and l.startswith(FENCE):
inside = False
continue
kept.append((n, l))
for c in l:
if skip:
skip = False
elif dq and c == "\\":
skip = True
elif c == '"':
dq = not dq
skip = False
lines = kept
numbers = [n for n, _ in lines]
joined = strip_comments("\n".join(l for _, l in lines))
ws = " \t\n\r"
lead = len(joined) - len(joined.lstrip(ws))
numbers = numbers[joined[:lead].count("\n"):]
body = joined.strip(ws)
numbers = numbers[: len(body.split("\n"))]
return body, numbers
# ---------------------------------------------------------------------------
# §11.2 lexing
def is_start(c):
return ("a" <= c <= "z") or ("A" <= c <= "Z") or c == "_"
def is_part(c):
return is_start(c) or ("0" <= c <= "9")
def is_digit(c):
return "0" <= c <= "9"
VALUE_KINDS = {"ident", "state", "str", "num", "badnum", "true", "false", "null"}
def ends_value(tok):
return tok["k"] in VALUE_KINDS or (tok["k"] == "p" and tok["v"] in (")", "]"))
def decode_dq(raw):
try:
v = json.loads('"' + raw + '"')
except ValueError:
return raw
if any(0xD800 <= ord(ch) <= 0xDFFF for ch in v):
return raw
return v
def decode_sq(raw):
out = []
i = 0
while i < len(raw):
c = raw[i]
if c != "\\":
out.append(c)
i += 1
continue
if i + 1 < len(raw):
e = raw[i + 1]
out.append("\n" if e == "n" else "\t" if e == "t" else e)
i += 2
return "".join(out)
TWO = ("==", "!=", ">=", "<=", "&&", "||")
ONE = {"=": "=", ">": ">", "<": "<", "+": "+", "-": "-", "*": "*", "/": "/", "%": "%", "&": "&&", "|": "||",
"!": "!", "?": "?", ":": ":", ".": ".", ",": ",", "(": "(", ")": ")", "[": "[", "]": "]", "{": "{", "}": "}"}
def lex(t):
toks = []
i, n, line = 0, len(t), 0
def add(k, v=None, start=None, open_=False):
toks.append({"k": k, "v": v, "line": line if start is None else start, "open": open_})
while i < n:
c = t[i]
if c in " \t":
i += 1
elif c == "\n":
add("nl")
line += 1
i += 1
elif c in "\"'":
start = line
j = i + 1
closed = False
while j < n:
d = t[j]
if d == "\\":
if j + 1 < n and t[j + 1] == "\n":
line += 1
j += 2
continue
if d == "\n":
line += 1
if d == c:
closed = True
break
j += 1
raw = t[i + 1: min(j, n)]
if not closed:
trailing = len(raw) - len(raw.rstrip("\\"))
if trailing % 2 == 1:
raw += "\\"
add("str", decode_dq(raw) if c == '"' else decode_sq(raw), start, not closed)
i = j + 1 if closed else n
elif c in "$@" and i + 1 < n and is_start(t[i + 1]):
j = i + 1
while j < n and is_part(t[j]):
j += 1
if c == "$":
add("state", t[i:j])
else:
add("at", t[i + 1:j])
i = j
elif is_start(c):
j = i
while j < n and is_part(t[j]):
j += 1
w = t[i:j]
if w in ("true", "false", "null"):
add(w, w)
else:
add("ident", w)
i = j
elif is_digit(c) or (c == "-" and i + 1 < n and is_digit(t[i + 1]) and not (toks and ends_value(toks[-1]))):
j = i + 1 if c == "-" else i
while j < n and is_digit(t[j]):
j += 1
if j + 1 < n and t[j] == "." and is_digit(t[j + 1]):
j += 1
while j < n and is_digit(t[j]):
j += 1
bad = False
if j < n and t[j] in "eE":
j += 1
if j < n and t[j] in "+-":
j += 1
d0 = j
while j < n and is_digit(t[j]):
j += 1
bad = j == d0
text = t[i:j]
if bad:
add("badnum", text)
else:
toks.append({"k": "num", "v": float(text), "text": text, "line": line, "open": False})
i = j
else:
if t[i:i + 2] in TWO:
add("p", t[i:i + 2])
i += 2
continue
if c in ONE:
add("p", ONE[c])
i += 1
return toks
# ---------------------------------------------------------------------------
# §11.3 statements
def is_p(tok, v):
return tok["k"] == "p" and tok["v"] == v
def split(toks, finished):
out = []
pos, n = 0, len(toks)
while pos < n:
while pos < n and toks[pos]["k"] == "nl":
pos += 1
if pos >= n:
break
t0 = toks[pos]
if t0["k"] not in ("ident", "state") or pos + 1 >= n or not is_p(toks[pos + 1], "="):
while pos < n and toks[pos]["k"] != "nl":
pos += 1
continue
name, state, line = t0["v"], t0["k"] == "state", t0["line"]
pos += 2
expr = []
depth = 0
tern = [] # [in second branch, has operand]
complete = finished
boundary = False
while pos < n:
t = toks[pos]
if t["k"] == "nl":
if depth == 0 and not tern:
peek = pos + 1
while peek < n and toks[peek]["k"] == "nl":
peek += 1
if peek < n and is_p(toks[peek], "?"):
pos += 1
continue
boundary = True
if peek < n:
complete = True
break
pos += 1
continue
operand = False
if t["k"] == "p" and t["v"] in "([{":
depth += 1
elif t["k"] == "p" and t["v"] in ")]}":
if depth > 0:
depth -= 1
operand = depth == 0
elif is_p(t, "?") and depth == 0:
tern.append([False, False])
elif is_p(t, ":") and depth == 0:
if tern and not tern[-1][0]:
tern[-1] = [True, False]
elif t["k"] in VALUE_KINDS:
operand = depth == 0
if operand and tern:
tern[-1][1] = True
if tern[-1][0]:
tern.pop()
expr.append(t)
pos += 1
opened = (not boundary) and (depth > 0 or (bool(expr) and expr[-1]["open"]))
out.append({"name": name, "state": state, "line": line, "toks": expr, "complete": complete, "open": opened})
return out
# ---------------------------------------------------------------------------
# §11.4 expressions
def close(toks):
out, stack = [], []
pairs = {"(": ")", "[": "]", "{": "}"}
for t in toks:
if t["k"] == "p" and t["v"] in pairs:
stack.append(pairs[t["v"]])
elif t["k"] == "p" and t["v"] in ")]}":
if stack and stack[-1] == t["v"]:
stack.pop()
else:
continue
out.append(t)
line = toks[-1]["line"] if toks else 0
while stack:
out.append({"k": "p", "v": stack.pop(), "line": line, "open": False})
return out
class Stop(Exception):
def __init__(self, line):
self.line = line
class P:
def __init__(self, toks):
self.t, self.i = toks, 0
def peek(self):
return self.t[self.i] if self.i < len(self.t) else None
def at(self, v):
x = self.peek()
return x is not None and is_p(x, v)
def line(self):
x = self.peek()
if x is None:
x = self.t[-1] if self.t else None
return x["line"] if x else 0
def stop(self):
raise Stop(self.line())
def expect(self, v):
if not self.at(v):
self.stop()
self.i += 1
def ternary(self):
c = self.binary(0)
if self.at("?"):
self.i += 1
a = self.ternary()
self.expect(":")
b = self.ternary()
return {"e": "ternary", "line": c["line"]}
return c
LEVELS = [("||",), ("&&",), ("==", "!="), (">", "<", ">=", "<="), ("+", "-"), ("*", "/", "%")]
def binary(self, lvl):
if lvl == len(self.LEVELS):
return self.unary()
left = self.binary(lvl + 1)
while True:
x = self.peek()
if x is None or x["k"] != "p" or x["v"] not in self.LEVELS[lvl]:
return left
op = x["v"]
self.i += 1
self.binary(lvl + 1)
left = {"e": "binary", "op": op, "line": left["line"]}
def unary(self):
if self.at("!") or self.at("-"):
line = self.line()
self.i += 1
self.unary()
return {"e": "unary", "line": line}
return self.postfix()
def postfix(self):
e = self.primary()
while True:
if self.at("."):
self.i += 1
self.key()
e = {"e": "member", "line": e["line"]}
elif self.at("["):
self.i += 1
self.ternary()
self.expect("]")
e = {"e": "index", "line": e["line"]}
else:
return e
def key(self):
x = self.peek()
if x is None:
self.stop()
self.i += 1
k = x["k"]
if k in ("ident", "str"):
return x["v"]
if k == "state":
return x["v"][1:]
if k == "num":
return x["text"]
if k == "badnum":
return x["v"]
if k in ("true", "false", "null"):
return k
if k == "at":
return "@" + x["v"]
return "?"
def items(self, closer):
v = []
while True:
while self.at(","):
self.i += 1
if self.at(closer):
self.i += 1
return v
if self.peek() is None:
self.stop()
v.append(self.ternary())
def primary(self):
x = self.peek()
if x is None:
self.stop()
line = x["line"]
k = x["k"]
self.i += 1
if k == "str":
return {"e": "str", "v": x["v"], "line": line}
if k == "num":
return {"e": "num", "v": x["v"], "line": line}
if k == "badnum":
self.i -= 1
self.stop()
if k in ("true", "false"):
return {"e": "bool", "v": k == "true", "line": line}
if k == "null":
return {"e": "null", "line": line}
if k == "p" and x["v"] == "[":
return {"e": "list", "items": self.items("]"), "line": line}
if k == "p" and x["v"] == "{":
entries = []
while True:
while self.at(","):
self.i += 1
if self.at("}"):
self.i += 1
break
if self.peek() is None:
self.stop()
kk = self.key()
self.expect(":")
entries.append((kk, self.ternary()))
return {"e": "object", "entries": entries, "line": line}
if k == "p" and x["v"] == "(":
e = self.ternary()
self.expect(")")
return e
if k == "ident":
if self.at("(") and "A" <= x["v"][0] <= "Z":
self.i += 1
return {"e": "call", "at": False, "name": x["v"], "args": self.items(")"), "line": line}
return {"e": "ref", "name": x["v"], "line": line}
if k == "at":
self.expect("(")
return {"e": "call", "at": True, "name": x["v"], "args": self.items(")"), "line": line}
if k == "state":
if self.at("="):
self.i += 1
self.ternary()
return {"e": "assign", "line": line}
return {"e": "state", "name": x["v"], "line": line}
self.i -= 1
self.stop()
def parse_tokens(toks):
if not toks:
raise Stop(None)
p = P(toks)
e = p.ternary()
if p.i < len(toks):
p.stop()
return e
# ---------------------------------------------------------------------------
# §11.5 into a joule-ui tree
BUILTINS = {"Count", "First", "Last", "Sum", "Avg", "Min", "Max", "Filter", "Sort", "Round", "Abs", "Floor", "Ceil", "Each"}
STATE_ACTIONS = {"Set", "Reset", "Run"}
def T(v):
return {"type": "text", "value": v}
class Ingest:
def __init__(self, lib, finished, lines):
self.reg = lib["widgets"]
self.lib = lib
self.finished = finished
self.lines = lines
self.errors = []
self.unresolved = set()
self.visited = set()
self.resolving = []
self.occ = {}
self.binds = {}
def src(self, line):
return self.lines[line] if 0 <= line < len(self.lines) else 0
def err(self, stmt, line, code, component=None, prop=None):
self.errors.append({"line": self.src(line), "statement": stmt, "code": code, "component": component, "prop": prop, "warning": False})
def reference(self, name):
b = self.binds.get(name)
if b is None or name in self.resolving:
self.unresolved.add(name)
return ("unresolved",)
if b["kind"] != "value":
return ("refused",)
self.visited.add(name)
self.occ[name] = self.occ.get(name, 0) + 1
key = name if self.occ[name] == 1 else "%s#%d" % (name, self.occ[name])
self.resolving.append(name)
v = self.eval(b["expr"], name, key)
self.resolving.pop()
return v
def eval(self, e, stmt, key):
k = e["e"]
if k == "str":
return ("data", T(e["v"]))
if k == "num":
return ("data", {"type": "number", "value": e["v"]})
if k == "bool":
return ("data", {"type": "bool", "value": e["v"]})
if k == "null":
return ("null",)
if k == "ref":
return self.reference(e["name"])
if k in ("state", "unary", "binary", "ternary", "member", "index", "assign"):
self.err(stmt, e["line"], "not-declarative")
return ("refused",)
if k == "list":
return ("list", [self.eval(it, stmt, "%s/%d" % (key, i)) for i, it in enumerate(e["items"])])
if k == "object":
return ("object", [(kk, self.eval(v, stmt, "%s/%s" % (key, kk))) for kk, v in e["entries"]])
# a call
name, args = e["name"], e["args"]
if not e["at"]:
if name in ("Query", "Mutation"):
self.err(stmt, e["line"], "inline-reserved")
return ("refused",)
if name == "Action":
return self.action_list(args, stmt, e["line"], key)
return self.build(name, args, e["line"], stmt, key)
if name in BUILTINS or name in STATE_ACTIONS:
self.err(stmt, e["line"], "not-declarative")
return ("refused",)
if name == "Action":
return self.action_list(args, stmt, e["line"], key)
items, not_yet, refused = [], False, False
for i, a in enumerate(args):
r = self.data(self.eval(a, stmt, "%s/%d" % (key, i)), stmt, a["line"], True)
if r == "not-yet":
not_yet = True
elif r == "refused":
refused = True
else:
items.append(r)
if refused:
return ("refused",)
if not_yet:
return ("held",)
payload = None if not items else items[0] if len(items) == 1 else {"type": "list", "value": items}
return ("action", {"type": "action", "value": {"id": name, "payload": payload}})
def action_list(self, args, stmt, line, key):
if len(args) == 1 and args[0]["e"] == "list" and len(args[0]["items"]) == 1:
one = args[0]["items"][0]
v = self.eval(one, stmt, key + "/0")
if v[0] == "action":
return v
if v[0] in ("unresolved", "held"):
return ("held",)
if v[0] == "refused":
return ("refused",)
self.err(stmt, one["line"], "type-mismatch")
return ("refused",)
self.err(stmt, line, "not-declarative")
return ("refused",)
def data(self, v, stmt, line, keep_null):
"""A value placed as data: a PropValue, "not-yet" or "refused"."""
k = v[0]
if k in ("data", "action"):
return v[1]
if k == "null":
return {"type": "null"} if keep_null else "not-yet"
if k == "list":
out = []
for it in v[1]:
r = self.data(it, stmt, line, True)
if isinstance(r, dict):
out.append(r)
return {"type": "list", "value": out}
if k == "object":
out = {}
for kk, it in v[1]:
r = self.data(it, stmt, line, True)
if isinstance(r, dict):
out[kk] = r
return {"type": "object", "value": out}
if k == "widget":
self.err(stmt, line, "type-mismatch", v[1]["name"])
return "refused"
if k in ("unresolved", "held"):
return "not-yet"
return "refused"
def build(self, name, args, line, stmt, key):
s = self.reg.get(name)
if s is None:
self.err(stmt, line, "unknown-component", name)
return ("refused",)
order = s["positional"]
if len(args) > len(order):
self.err(stmt, args[len(order)]["line"], "excess-args", name)
props, absent, children = {}, {}, []
for slot, arg in zip(order, args):
if slot == "children":
v = self.eval(arg, stmt, key)
if v[0] == "list":
for it in v[1]:
if it[0] == "widget":
children.append((it[1], it[2]))
elif it[0] in ("unresolved", "held", "null", "refused"):
pass
else:
self.err(stmt, arg["line"], "type-mismatch", name, "children")
elif v[0] in ("null", "unresolved", "held", "refused"):
pass
else:
self.err(stmt, arg["line"], "type-mismatch", name, "children")
continue
v = self.eval(arg, stmt, "%s/%s" % (key, slot))
if v[0] == "null":
absent[slot] = "null"
elif v[0] in ("unresolved", "held"):
absent[slot] = "not-yet"
elif v[0] == "refused":
absent[slot] = "refused"
elif v[0] == "widget":
self.err(stmt, arg["line"], "type-mismatch", name, slot)
absent[slot] = "refused"
else:
r = self.data(v, stmt, arg["line"], False)
if isinstance(r, dict):
props[slot] = r
else:
absent[slot] = r
w = {"name": name, "props": props}
if children:
w["children"] = [c[0] for c in children]
child_nodes = [c[1] for c in children]
while True:
errs = validate(w, self.reg)
if not errs:
break
if not self.finished and any(x["kind"] == "missing_prop" and x["path"] == "$" and absent.get(x["prop"]) == "not-yet" for x in errs):
return ("held",)
drop = False
drop_children = []
before = len(w["props"])
for x in errs:
kind, path = x["kind"], x["path"]
if kind == "missing_prop" and path == "$":
why = absent.get(x["prop"])
if why == "null":
self.err(stmt, line, "null-required", name, x["prop"])
elif why != "refused":
self.err(stmt, line, "missing-required", name, x["prop"])
drop = True
elif kind == "wrong_prop_type" and path == "$":
self.err(stmt, line, "type-mismatch", name, x["prop"])
if s["props"][x["prop"]].get("required"):
drop = True
else:
del w["props"][x["prop"]]
elif kind == "extra_prop" and path == "$":
self.err(stmt, line, "type-mismatch", name, x["prop"])
del w["props"][x["prop"]]
elif kind == "disallowed_child":
idx = child_index(path)
if idx is None:
drop = True
continue
first = child_nodes[idx][0] if idx < len(child_nodes) and child_nodes[idx] else None
self.errors.append({"line": first["line"] if first and first["line"] > 0 else self.src(line),
"statement": first["statement"] if first else stmt,
"code": "disallowed-child", "component": x["child"], "prop": None, "warning": False})
drop_children.append(idx)
else:
drop = True
if drop:
return ("refused",)
drop_children = sorted(set(drop_children))
changed = bool(drop_children) or len(w["props"]) != before
for i in reversed(drop_children):
del w["children"][i]
del child_nodes[i]
if "children" in w and not w["children"]:
del w["children"]
if not changed:
return ("refused",)
nodes = [{"key": key, "line": self.src(line), "statement": stmt}]
for cn in child_nodes:
nodes.extend(cn)
return ("widget", w, nodes)
def child_index(path):
pre = "$.children["
if not path.startswith(pre) or not path.endswith("]"):
return None
mid = path[len(pre):-1]
return int(mid) if mid.isdigit() else None
def snapshot(lib, text, finished):
body, lines = prepare(text)
toks = lex(body)
raws = split(toks, finished)
incomplete = bool(raws) and raws[-1]["open"]
ing = Ingest(lib, finished, lines)
src = ing.src
errors, statements, binds = [], [], {}
for order, r in enumerate(raws):
tk = close(r["toks"]) if r["open"] else r["toks"]
line = src(r["line"])
try:
e = parse_tokens(tk)
except Stop as stop:
if r["complete"]:
at = line if stop.line is None or not tk else src(stop.line)
errors.append({"line": at, "statement": r["name"], "code": "syntax", "component": None, "prop": None, "warning": False})
statements.append({"name": r["name"], "line": line, "kind": "syntax", "complete": True})
continue
if e["e"] == "call" and not e["at"] and e["name"] == "Query":
kind = "query"
elif e["e"] == "call" and not e["at"] and e["name"] == "Mutation":
kind = "mutation"
elif r["state"]:
kind = "state"
else:
kind = "value"
statements.append({"name": r["name"], "line": line, "kind": kind, "complete": r["complete"]})
if r["complete"] and kind != "value":
errors.append({"line": line, "statement": r["name"], "code": "not-declarative", "component": None, "prop": None, "warning": False})
prev = binds.get(r["name"])
if prev is not None and prev["complete"] and not r["complete"] and r["open"]:
continue
binds[r["name"]] = {"line": line, "complete": r["complete"], "kind": kind, "expr": e, "order": order}
ing.binds = binds
entry = None
if "root" in binds:
entry = ("root", False)
elif lib.get("root"):
vals = sorted((b["order"], n) for n, b in binds.items() if b["kind"] == "value")
for _, n in vals:
ex = binds[n]["expr"]
if ex["e"] == "call" and not ex["at"] and ex["name"] == lib["root"]:
entry = (n, True)
break
tree, nodes = None, []
if entry:
v = ing.reference(entry[0])
if v[0] == "widget":
tree, nodes = v[1], v[2]
errors.extend(ing.errors)
if finished:
if entry is None:
code = "parse-failed" if not statements and body != "" else "no-root"
errors.append({"line": 0, "statement": "", "code": code, "component": None, "prop": None, "warning": False})
else:
line = binds[entry[0]]["line"]
if tree is None:
errors.append({"line": line, "statement": entry[0], "code": "no-root", "component": None, "prop": None, "warning": False})
elif entry[1]:
errors.append({"line": line, "statement": entry[0], "code": "no-root", "component": None, "prop": None, "warning": True})
if not finished:
errors = [x for x in errors if x["statement"] not in binds or binds[x["statement"]]["complete"]]
def opt(v):
return (0, "") if v is None else (1, v)
def ekey(x):
return (x["line"], x["statement"], x["code"], opt(x["component"]), opt(x["prop"]), x["warning"])
errors.sort(key=ekey)
kept = []
for x in errors:
if not kept or ekey(kept[-1]) != ekey(x):
kept.append(x)
orphans = sorted(n for n, b in binds.items() if b["kind"] == "value" and n not in ing.visited) if entry else []
if tree is not None and validate(tree, ing.reg):
tree, nodes = None, []
return {"tree": tree, "nodes": nodes, "errors": kept, "unresolved": sorted(ing.unresolved), "orphans": orphans,
"incomplete": incomplete, "statements": statements}
def stated(s):
"""A snapshot as the pack states it (§11.9)."""
errs = []
for x in s["errors"]:
e = {"line": x["line"], "statement": x["statement"], "code": x["code"]}
if x["component"] is not None:
e["component"] = x["component"]
if x["prop"] is not None:
e["prop"] = x["prop"]
if x["warning"]:
e["warning"] = True
errs.append(e)
return {"tree": s["tree"], "nodes": s["nodes"], "errors": errs, "unresolved": s["unresolved"],
"orphans": s["orphans"], "incomplete": s["incomplete"]}
class Stream:
def __init__(self, lib):
self.lib, self.text = lib, ""
def push(self, chunk):
self.text += chunk
return stated(snapshot(self.lib, self.text, False))
def finish(self):
return stated(snapshot(self.lib, self.text, True))
# ---------------------------------------------------------------------------
# §11.7 the canonical form
def is_name(s):
return bool(s) and is_start(s[0]) and all(is_part(c) for c in s)
def jstr(s):
out = ['"']
for ch in s:
o = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == "\\":
out.append("\\\\")
elif ch == "\b":
out.append("\\b")
elif ch == "\f":
out.append("\\f")
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif o < 0x20:
out.append("\\u%04x" % o)
else:
out.append(ch)
out.append('"')
return "".join(out)
def num_text(x):
"""The shortest decimal that reads back to x, positional, no exponent, no `.0`."""
if x != x or x in (float("inf"), float("-inf")):
raise ValueError("not finite")
t = format(Decimal(repr(x)), "f")
if "." in t:
t = t.rstrip("0").rstrip(".")
return t
RESERVED_IDS = {"Action"} | BUILTINS | STATE_ACTIONS
def val(v):
t = v["type"]
if t == "null":
return "null"
if t == "bool":
return "true" if v["value"] else "false"
if t == "number":
return num_text(v["value"])
if t == "text":
return jstr(v["value"])
if t == "list":
return "[" + ", ".join(val(i) for i in v["value"]) + "]"
if t == "object":
parts = []
for k in sorted(v["value"]):
kk = k if is_name(k) and k not in ("true", "false", "null") else jstr(k)
parts.append("%s: %s" % (kk, val(v["value"][k])))
return "{" + ", ".join(parts) + "}"
if t == "action":
aid, payload = v["value"]["id"], v["value"]["payload"]
if not is_name(aid) or aid in RESERVED_IDS:
raise ValueError("action id")
if payload is None:
inner = ""
elif payload["type"] == "list" and len(payload["value"]) >= 2:
inner = ", ".join(val(i) for i in payload["value"])
else:
inner = val(payload)
return "@%s(%s)" % (aid, inner)
raise ValueError(t)
def write(tree, lib):
reg = lib["widgets"]
if validate(tree, reg):
raise ValueError("invalid")
flat = []
def walk(w):
flat.append(w)
for c in w.get("children", []):
walk(c)
walk(tree)
names, counts = [], {}
for i, w in enumerate(flat):
if i == 0:
names.append("root")
continue
base = w["name"][0].lower() + w["name"][1:]
counts[base] = counts.get(base, 0) + 1
names.append("%s%d" % (base, counts[base]))
out = []
idx = [0]
def size(w):
return 1 + sum(size(c) for c in w.get("children", []))
def emit(w):
me = idx[0]
idx[0] += 1
if not ("A" <= w["name"][0] <= "Z") or w["name"] in ("Query", "Mutation", "Action") or not is_name(w["name"]):
raise ValueError("not callable")
s = reg[w["name"]]
order = s["positional"]
for p in w.get("props", {}):
if p not in order:
raise ValueError("not positional")
child_names, at = [], me + 1
for c in w.get("children", []):
child_names.append(names[at])
at += size(c)
args = []
for slot in order:
if slot == "children":
args.append("[" + ", ".join(child_names) + "]" if child_names else None)
continue
pv = w.get("props", {}).get(slot)
if pv is None:
args.append(None)
elif pv["type"] == "null":
raise ValueError("null prop")
else:
args.append(val(pv))
while args and args[-1] is None:
args.pop()
out.append("%s = %s(%s)\n" % (names[me], w["name"], ", ".join("null" if a is None else a for a in args)))
for c in w.get("children", []):
emit(c)
emit(tree)
return "".join(out)
# ---------------------------------------------------------------------------
# the pack
def load(d):
files = {}
for n in sorted(os.listdir(d)):
with open(os.path.join(d, n), "rb") as f:
files[n] = f.read()
return files
def norm_tree(t):
"""A tree for comparison: JSON numbers as floats."""
return json.loads(json.dumps(t)) if t is not None else None
def check(files):
"""Every disagreement, as a list of lines; empty when the pack agrees."""
bad = []
man = json.loads(files["MANIFEST.json"])
for n, digest in man["files"].items():
if n not in files:
bad.append("MANIFEST names %s, which the pack lacks" % n)
elif "sha256:" + hashlib.sha256(files[n]).hexdigest() != digest:
bad.append("%s: its SHA-256 is not the manifest's" % n)
for n in files:
if n != "MANIFEST.json" and n not in man["files"]:
bad.append("%s is not in the manifest" % n)
if bad:
# A file the manifest does not name as it is: nothing in it is relied on.
return bad
lib = json.loads(files["library.json"])
def same(a, b):
return json.loads(json.dumps(a, sort_keys=True)) == json.loads(json.dumps(b, sort_keys=True))
for c in json.loads(files["l1-statements.json"])["cases"]:
s = snapshot(lib, c["input"], True)
if not same(s["statements"], c["statements"]) or s["incomplete"] != c["incomplete"]:
bad.append("L1 %s: got %s" % (c["name"], json.dumps(s["statements"])))
l2 = {}
for c in json.loads(files["l2-ingest.json"])["cases"]:
s = stated(snapshot(lib, c["input"], True))
l2[c["name"]] = s
if not same(s, c["expected"]):
bad.append("L2 %s: got %s" % (c["name"], json.dumps(s)[:600]))
for c in json.loads(files["l3-stream.json"])["cases"]:
st = Stream(lib)
for i, ch in enumerate(c["chunks"]):
s = st.push(ch)
if not same(s, c["after_each"][i]):
bad.append("L3 %s after chunk %d: got %s" % (c["name"], i, json.dumps(s)[:600]))
end = st.finish()
if not same(end, c["at_end"]):
bad.append("L3 %s at the end: got %s" % (c["name"], json.dumps(end)[:600]))
if not same(end, stated(snapshot(lib, "".join(c["chunks"]), True))):
bad.append("L3 %s: the end of the stream is not the batch parse" % c["name"])
for c in json.loads(files["l4-write.json"])["cases"]:
try:
text = write(c["tree"], lib)
except ValueError as e:
bad.append("L4 %s: not writable (%s)" % (c["name"], e))
continue
if text != c["text"]:
bad.append("L4 %s: wrote %r" % (c["name"], text))
back = snapshot(lib, c["text"], True)
if not same(back["tree"], c["tree"]) or back["errors"]:
bad.append("L4 %s: the text does not parse back to the tree" % c["name"])
for c in json.loads(files["l5-refusals.json"])["cases"]:
s = stated(snapshot(lib, c["input"], True))
if not any(e["code"] == c["code"] and e["line"] == c["line"] for e in s["errors"]):
bad.append("L5 %s: not refused %s at line %d" % (c["name"], c["code"], c["line"]))
if not same(s, c["expected"]):
bad.append("L5 %s: got %s" % (c["name"], json.dumps(s)[:600]))
return bad
def mutations(files):
"""Changed vectors, each of which the verifier must refuse."""
def edit(name, f):
doc = json.loads(files[name])
f(doc)
out = dict(files)
out[name] = (json.dumps(doc, indent=2, sort_keys=True) + "\n").encode()
# keep the manifest true, so that the content check is what refuses it
man = json.loads(out["MANIFEST.json"])
man["files"][name] = "sha256:" + hashlib.sha256(out[name]).hexdigest()
out["MANIFEST.json"] = (json.dumps(man, indent=2, sort_keys=True) + "\n").encode()
return out
def case(doc, n):
return next(c for c in doc["cases"] if c["name"] == n)
def first_text(t):
if t and "props" in t:
for k, v in t["props"].items():
if v.get("type") == "text":
return t["props"], k
for c in (t or {}).get("children", []):
r = first_text(c)
if r:
return r
return None
def change_text(doc, n, key="expected"):
props, k = first_text(case(doc, n)[key]["tree"])
props[k]["value"] += "!"
flipped = bytearray(files["l2-ingest.json"])
flipped[100] ^= 0x01
yield "a flipped byte under the manifest", dict(files, **{"l2-ingest.json": bytes(flipped)})
yield "L1: a statement's line moved", edit("l1-statements.json", lambda d: d["cases"][0]["statements"][1].__setitem__("line", 9))
yield "L1: complete flipped", edit("l1-statements.json", lambda d: d["cases"][0]["statements"][0].__setitem__("complete", False))
yield "L2: a text changed", edit("l2-ingest.json", lambda d: change_text(d, "hoisting"))
yield "L2: an unresolved name dropped", edit("l2-ingest.json", lambda d: case(d, "unresolved-left-out")["expected"]["unresolved"].clear())
yield "L2: an orphan dropped", edit("l2-ingest.json", lambda d: case(d, "orphans")["expected"]["orphans"].clear())
yield "L2: a key renamed", edit("l2-ingest.json", lambda d: case(d, "used-twice")["expected"]["nodes"][2].__setitem__("key", "t"))
yield "L3: a pending title already whole", edit("l3-stream.json", lambda d: change_text(d, "pending-title-grows", "at_end"))
yield "L3: an error not withheld", edit("l3-stream.json", lambda d: case(d, "errors-wait-for-completion")["after_each"][0]["errors"].append({"line": 3, "statement": "b", "code": "type-mismatch", "component": "Text", "prop": "text"}))
yield "L4: the canonical text changed", edit("l4-write.json", lambda d: d["cases"][0].__setitem__("text", d["cases"][0]["text"].replace("card1", "c1", 1)))
yield "L5: a refusal's line moved", edit("l5-refusals.json", lambda d: case(d, "unknown-component").__setitem__("line", 2))
yield "L5: a refused widget kept", edit("l5-refusals.json", lambda d: case(d, "unknown-component")["expected"]["tree"]["children"].append({"name": "Chartt", "props": {}}))
yield "L5: a code changed", edit("l5-refusals.json", lambda d: case(d, "disallowed-child")["expected"]["errors"][0].__setitem__("code", "type-mismatch"))
def main(argv):
mut = "--mutations" in argv
args = [a for a in argv if a != "--mutations"]
d = args[0] if args else DEFAULT_PACK
files = load(d)
if mut:
missed = 0
n = 0
for what, changed in mutations(files):
n += 1
if check(changed):
print("refused: %s" % what)
else:
print("NOT REFUSED: %s" % what)
missed += 1
if check(files):
print("the unchanged pack does not verify")
return 1
print("%d mutation(s), %d not refused" % (n, missed))
return 1 if missed else 0
bad = check(files)
for b in bad:
print(b)
counts = {n: len(json.loads(files[n])["cases"]) for n in files if n[:1] == "l" and n[1:2].isdigit()}
print("joule-ui-lang/v1: %s; %d disagreement(s)" % (", ".join("%s %d" % (k.split(".")[0], v) for k, v in sorted(counts.items())), len(bad)))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))