Parse and statically validate an expression.
The expression data.
The expression text.
Entity options, including the owning parent logic and
the optional scope declaring which identifiers are legal here.
ReadonlyscopeThe call site's declared bindings, when one was supplied. Identifiers outside it are rejected at construction; without a scope, any identifier is accepted and resolved from the evaluation context. Transient — the scope belongs to the call site, not to the persisted expression.
ReadonlysourceThe original expression source string.
The serialization discriminator for this instance — the concrete class's static Kind. Written into the JSON by toJSON under the kind key and read back by sohl.utils.defaultFromJSON to select the constructor. Derived from the class, never stored per-instance.
The distinct attribute shortcodes an expression reads via the attr
context namespace (attr.str, attr["dex"]) — the memberRefs
walk specialized to "attr". Lowercased and de-duplicated.
The referenced attribute shortcodes, in first-seen order.
The distinct member names read off base within the arguments of calls
to callee, anywhere in the expression — e.g.
callArgMemberRefs("sb") on sb(attr.str, attr.dex) + attr.aur / 10
yields ["str", "dex"], excluding the attr.aur that sits outside the
call.
Where memberRefs answers "which members does this expression reference", this answers "which members does this call consume" — the distinction between an attribute a Skill Base is based on and one that merely adjusts the result (#1175). Argument order is preserved, so the first name returned is the call's primary argument.
Nested calls inside the arguments are descended into, and repeated calls to the same helper are unioned. Names are lowercased and de-duplicated, in first-seen order.
The helper name whose call arguments to inspect (e.g. "sb").
The base identifier whose member accesses to collect
(defaults to "attr").
The distinct, lowercased member names, in first-seen order; empty
when callee is never called.
Deep-copy this entity, re-parenting the copy under parent with no other
changes. Shorthand for clone({}, { parent }).
The Logic to own the cloned entity.
The cloned entity.
Deep-copy this entity, optionally overriding fields and clone options.
Field overrides applied to the clone.
Clone options (e.g. a new parent).
The cloned entity.
The distinct member names read off a given base identifier anywhere in the
expression — e.g. attrRefs() returns every attr.<name> accessed. Both
dot access (attr.str) and string-literal computed access (attr["str"])
are collected; the names are lowercased and de-duplicated, in first-seen
order. Computed access by a non-literal key (attr[x]) cannot be resolved
statically and is ignored.
A read-only static walk over the already-validated AST (no re-parse). Used
to derive a skill's attribute dependencies from its Skill-Base expression —
e.g. gating fate off when the formula reads attr.aur — without a regex on
the source.
The base identifier whose member accesses to collect
(defaults to "attr").
The distinct, lowercased member names, in first-seen order.
Serialize to a plain object — only the source is persisted; the AST is rebuilt from it on reconstruction.
The serialized expression data.
StaticvalidateStatically check whether source is a well-formed, allowlist-valid
expression — without evaluating it or requiring a live parent Logic.
This is the shared validation seam for editing surfaces: a DataModel
field's _validateType, or the live "is this valid?" feedback in the
expression editor dialog. It runs the exact same parse-and-allowlist path
the runtime uses at construction (a bad operator, a denied key, a method
or unregistered-helper call, or a parse failure all fail here), so editor
validity never drifts from runtime behavior. Only the constructor's static
checks run; the parent is a throwaway stub because static validation never
reads it (only evaluate does, for parent-bound helpers).
A blank, whitespace-only, null, or undefined source is treated as
valid (unset) — an empty formula field means "no expression", not a
broken one.
The expression text to check (or blank/nullish for unset).
Optionalscope: ExpressionScopeThe call site's declared bindings, when known. Supplying it also rejects an identifier outside the scope, so the editor flags an out-of-scope name as you type rather than leaving it to fail at runtime.
undefined when the source is valid or unset; otherwise the
SafeExpressionError message describing why it is invalid.
A parsed, validated, reusable safe expression — SoHL's way to evaluate a condition that was written as a string (for example, a predicate a GM types into an action's
triggerfield, or an Active Effect'stest) without the dangers ofeval.It exists because such strings come from data, not source code: they must be evaluated against live game objects, yet must never be able to run arbitrary code, reach the DOM or network, or escape through the prototype chain.
SafeExpressionparses the string into a syntax tree, statically validates it against a strict allowlist, then evaluates that tree by hand. Anything outside the allowed language is rejected — usually before it ever runs.It is a SohlEntity: only its source is persisted (via toJSON); the parsed AST is rebuilt in the constructor on revival.
For a hands-on authoring guide — the grammar, the full standard-helper reference (exact signatures and return values), the bindings each call site provides, and worked examples — see the Expressions and Scripts concept doc. The summary below covers the essentials.
Using it
Two steps: build once, then evaluate as often as you like.
new SafeExpression({ source }, { parent })parses and validatessourceimmediately. If the string uses anything unsupported it throws a SafeExpressionError right here, so a bad predicate fails loudly at setup time instead of silently at use time. Construction is the costly step; keep the instance and reuse it.expr.evaluate(context?)runs the expression againstcontext, a plain object of variable bindings. Every bare identifier in the expression is looked up by name incontext. It returns whatever the expression computes (for a predicate, a boolean; for a computed field, a number or string).Scopes — declaring what is in play
An expression's identifiers only mean something against the bindings its call site supplies, and that contract used to be implicit: each site built an ad-hoc object literal, so writing an identifier the site did not bind parsed cleanly and only threw at evaluation — where the caller caught it, logged a warning, and silently treated the feature as off (issue #1142).
Pass an ExpressionScope to close that gap. The scope declares the legal identifiers, and construction rejects anything outside them:
Only the root identifier of a member chain is checked —
itemLogic.foo.barvalidatesitemLogicand leaves the object graph alone, since the roots are a knowable list and the graph is not. The scope is optional: an expression built without one accepts any identifier and resolves it from the evaluation context, exactly as before.The language
Allowed: literals (
3,"orc",true), array literals ([1, 2]), identifiers resolved from the context, property access by dot or bracket (actor.name,tags["ranged"]), the operators=== !== < > <= >= + - * / %, the short-circuiting&&and||, the unary! - +, the ternarycond ? a : b, and calls to helpers (below).Rejected — at parse/validation time, before anything runs: assignment (
=), bitwise and loose-equality operators (& | == !=),typeof/new/delete/instanceof, statements (;,if,for), template and regex literals, and — importantly — method calls. You cannot writeactor.die(); the only callable values are the registered helpers.Helpers
Because method calls are banned, helpers are how you expose behavior to an expression. They come from the global expressionHelpers registry — the built-in library plus any world-loaded custom helpers — and are resolved by name when the expression is validated and evaluated.
Errors
Every failure — a parse error, an unsupported node, an unknown identifier at evaluation, or an attempt to extract a method — surfaces as a SafeExpressionError. Syntax and validation failures throw from the constructor; runtime failures throw from evaluate.
See