diff --git a/src/harel/cel.py b/src/harel/cel.py index 09871c4..a2d485d 100644 --- a/src/harel/cel.py +++ b/src/harel/cel.py @@ -41,9 +41,33 @@ def _to_cel(value: Any) -> Any: return value +def _from_cel(value: Any) -> Any: + """Normalize a celpy result to a canonical native/JSON Python value (SPEC §5.1). + + No guard-language wrapper type (``celpy.celtypes.*``) may cross the engine boundary, + so every CEL result is coerced to its native equivalent here — the single choke point + for esv assignments, published payloads, and spawn args. + """ + if isinstance(value, celtypes.BoolType): # subclasses int — check before IntType + return bool(value) + if isinstance(value, (celtypes.IntType, celtypes.UintType)): + return int(value) + if isinstance(value, celtypes.DoubleType): + return float(value) + if isinstance(value, celtypes.StringType): + return str(value) + if isinstance(value, celtypes.BytesType): + return bytes(value) + if isinstance(value, dict): # MapType (and native dict): normalize keys + values + return {_from_cel(k): _from_cel(v) for k, v in value.items()} + if isinstance(value, list): # ListType (and native list) + return [_from_cel(v) for v in value] + return value + + def evaluate(expr: str, bindings: dict[str, Any]) -> Any: - """Evaluate a CEL expression against ``bindings`` (esvs + event/id/parent).""" + """Evaluate a CEL expression, returning a canonical native/JSON value (§5.1).""" try: - return _program(expr).evaluate(_to_cel(bindings)) + return _from_cel(_program(expr).evaluate(_to_cel(bindings))) except celpy.CELEvalError as exc: # type: ignore[attr-defined] raise CelError(str(exc)) from exc diff --git a/tests/test_native_values.py b/tests/test_native_values.py new file mode 100644 index 0000000..8c16e82 --- /dev/null +++ b/tests/test_native_values.py @@ -0,0 +1,99 @@ +"""Values crossing the boundary are canonical native/JSON types (SPEC §5.1). + +CEL-produced values (assign RHS, published payloads, …) must not surface celpy wrapper +types (``IntType``/``DoubleType``/``BoolType``/``StringType``/``MapType``/``ListType``) +through the public API, snapshots, or `--json`. +""" + +from __future__ import annotations + +import json + +from harel import Host, load_definitions + +TYPES = """\ +id: types +events: + go: {} +top: + esvs: + i: { type: int, init: 0 } + f: { type: float, init: 0.0 } + b: { type: bool, init: false } + s: { type: string, init: "" } + m: { type: map, init: {} } + l: { type: list, init: [] } + initial: { transition_to: a } + states: + a: + on_events: + go: + action: + - { assign: { i: "1 + 2" } } + - { assign: { f: "1.5 + 0.5" } } + - { assign: { b: "1 < 2" } } + - { assign: { s: "'a' + 'b'" } } + - { assign: { m: "{'k': 1 + 1}" } } + - { assign: { l: "[1, 2, 3]" } } + transition_to: done_ + done_: {} +""" + + +def _run() -> object: + host = Host() + host.register_all(load_definitions(TYPES)) + inst = host.create_root(host.machines["types"], "r") + host.run_to_quiescence() + host.deliver("r", "go") + host.run_to_quiescence() + return inst + + +def test_cel_assignments_are_native_python() -> None: + esvs = _run().resolved_esvs() + assert type(esvs["i"]) is int + assert type(esvs["f"]) is float + assert type(esvs["b"]) is bool + assert type(esvs["s"]) is str + assert type(esvs["m"]) is dict + assert type(esvs["l"]) is list + # nested values too (no wrappers inside containers) + assert type(esvs["m"]["k"]) is int and esvs["m"]["k"] == 2 + assert [type(x) for x in esvs["l"]] == [int, int, int] + assert esvs["i"] == 3 and esvs["f"] == 2.0 and esvs["b"] is True and esvs["s"] == "ab" + + +def test_snapshot_contains_only_native_json_values() -> None: + snap = _run().to_snapshot() + json.dumps(snap) # must be plain-serializable + + def _walk(v: object) -> None: + # celpy wrappers subclass their natives, so json.dumps alone wouldn't catch them. + assert "celpy" not in type(v).__module__, f"celpy type in snapshot: {type(v)}" + if isinstance(v, dict): + for k, val in v.items(): + _walk(k) + _walk(val) + elif isinstance(v, list): + for x in v: + _walk(x) + + _walk(snap) + + +def test_no_celpy_type_leaks_anywhere_in_esvs() -> None: + esvs = _run().resolved_esvs() + + def _assert_native(v: object) -> None: + assert type(v).__module__ == "builtins", f"non-native value leaked: {type(v)}" + if isinstance(v, dict): + for k, val in v.items(): + _assert_native(k) + _assert_native(val) + elif isinstance(v, list): + for x in v: + _assert_native(x) + + for val in esvs.values(): + _assert_native(val)