StatifierBlocks.Compiler (StatifierBlocks v0.3.0)

Copy Markdown View Source

The one-way compile: a block document plus a palette in, one artifact out (ADR-0004 decisions 1-4, 6-7).

compile/3 is a total function of {document, palette}. No process state, no global registry, no IO, no clock - the same purity ADR-0002 decision 4 imposes on the callbacks, imposed on the pipeline that calls them. It returns {:ok, %StatifierBlocks.Compiled{}} or {:error, [%StatifierBlocks.Compiler.Finding{}]}, never raises, and never partially succeeds.

The pipeline

Each stage runs over the whole document; the first stage that produces errors stops the compile and reports every error it found. Stopping rather than accumulating across stages is deliberate - see StatifierBlocks.Compiler.Finding.

  1. Document - StatifierBlocks.Document.validate/1. Structural only; no palette is consulted.
  2. Resolve - every block through StatifierBlocks.Palette.resolve/2, which also applies an in-memory config migration (ADR-0002 decision 8). Nothing is written back.
  3. Config - every block's validate_config/1.
  4. Structure - StatifierBlocks.SlotValidation.validate/2 (slot arity, :undeclared_slot) and StatifierBlocks.Assignability.validate/3 (may this block land in this slot, by kind tag and by data-flow type - ADR-0003), reported together.
  5. Emit - bottom-up. Each block's emit/2 is called with its children already compiled and summarized, the scope-shaped cancel for any delayed send a direct child armed is added to its own state (StatifierBlocks.Compiler.Cancels), its emission is attributed (StatifierBlocks.Compiler.Attribution), and its child placeholders are spliced with those children's own emissions.
  6. Chart - serialize once through StatifierBlocks.Compiler.Serializer, which writes the bytes and the provenance map together, then run those bytes through Statifier.compile/2 and map every finding back through provenance (StatifierBlocks.Compiler.Chart).

Findings from every stage are reported in document order over blocks - StatifierBlocks.Document.blocks/1's pre-order - which is how upstream's own document-order sort survives the trip.

The Structure stage is whole

Decision 10's table names three things in this stage: slot arity, :undeclared_slot, and assignability. All three run here, and their findings are reported together rather than either short-circuiting the other: an undeclared slot key does not induce an assignability finding (a slot with no declaration gets slot_accepts :any, which admits everything), and an arity violation is a count, which no assignability rule reads. Neither is a consequence of the other - they are siblings, which is decision 10's own rule for what one stage reports - so StatifierBlocks.SlotValidation.validate/2 and StatifierBlocks.Assignability.validate/3 are both always run and their findings concatenated. Before this, the pipeline never visited a slot slots/1 did not declare, so an undeclared slot's blocks were absent from the emission rather than misplaced in it - a silent drop.

Options

  • :known_invoke_types - decision 8's opt-in lint. A set (or list) of invoke types the caller believes will be registered; every emitted type absent from it becomes a warning, never an error. See StatifierBlocks.Compiler.InvokeTypes.

  • :entry_type - ADR-0003 decision 4's caller-supplied context: the type flowing into the document's root. Defaults to absent, which StatifierBlocks.Assignability reads as :unknown. ADR-0004's own typespec lists only the first option, because it delegated assignability wholesale to ADR-0003 (decision 11) without noticing that ADR-0003's context is caller-supplied and therefore has to arrive through this function. This is that arrival, not a second decision about what assignability means.

  • :datamodel - the host's declared datamodel, which StatifierBlocks.Compiler.SensitivePaths reads to refuse a document that carries a declared-sensitive path into a position the chart evaluates against the datamodel (ADR-0002 decision 7's sensitive? key and the secrets rule behind it). Absent, or declaring nothing sensitive, the check does not run and produces nothing - absence is not unknown-ness (ADR-0005 11f). See StatifierBlocks.Compiler.SensitivePaths.datamodel/1 for the shapes it accepts, and that module for the criterion the refusal applies.

  • :child_use - compile this document for use as a child of another chart (ADR-0004's 2026-08-29 amendment, C1). The emission gains one top-level <final> per outcome the root block declares, reached from done.outcome.<root state id>.<outcome> and carrying <donedata><param name="outcome" expr="'<outcome>'"/></donedata>, which is how a child's outcome crosses the invoke boundary: raised events are internal to the session that raises them, so the only thing a parent observes is the completion event and the data the child sent with it (SCXML 3.7 and 5.5). Defaults to false, and a document compiled without it is byte-identical to what it was before the option existed. The parent half is StatifierBlocks.Core.Subchart.

  • :terminate - compile this document as a root document that finishes (ADR-0004's 2026-08-29 root-termination note). The emission gains one top-level <final> per outcome the root block declares, reached from done.outcome.<root state id>.<outcome> and carrying no <donedata>, so the session reaches :done when the root block completes. Without it a compiled root document never terminates: the root block's own outcome finals are children of the root compound state, so completing the root block raises done.outcome internally and the session stays active forever, which is what leaves a durable run uncompleted. Defaults to false, and a document compiled without it is byte-identical to what it was before the option existed.

    :terminate and :child_use are the same emission shaped for two different uses, and a document is compiled for one or the other: passing both is refused with an :emit finding rather than resolved silently, because both would put a transition on the same done.outcome event on the root block's own state and document order would quietly decide which top-level <final> a run reaches.

  • :declare - the <data> roots the host declares for this document (ADR-0004's 2026-08-29 host-declared-roots note): a list of {id, expr} pairs, in declaration order, where expr is either an expression written verbatim into the attribute or nil for a root that reads as undefined until something assigns it.

    Compiler.compile(document, palette, declare: [{"targets", nil}, {"parked", "false"}])

    Each pair becomes one StatifierBlocks.Compiler.DeclaredRoots declaration, prepended to the root block's own children before the hoist, so the host's roots lead the single <datamodel> in the order given and block-declared roots follow in document order. An id must be a bare lowercase identifier - core.invoke's assign_to rule - and an id the option repeats, or an entry that is not a well-formed pair, is refused as an Emit-stage finding against the root block. An id a block also declares is F6's :duplicate_binding against that block, through the same walk a nested loop's collision goes through.

    This is the compile call's declaration surface and the only one: the document has no key for it (ADR-0001) and no block type declares one. Absent or [] emits no <datamodel> unless a block declares a root, so a document compiled without it is byte-identical to what it was before the option existed. Run creation still wins over expr (SCXML 5.3.2) - a run seeded with a value for the id starts from that value, which is the engine's behaviour and not this package's.

Determinism (decision 6)

For a fixed {document canonical bytes, palette, compiler version}, the generated SCXML is byte-identical on every machine and every run, forever.

All three inputs are real, and StatifierBlocks.CompilationRecord records all three. What this module contributes to the guarantee is that it never iterates a bare map: slots are visited in slots/1 declaration order (ADR-0002 decision 6 made that order meaningful), children in document order, and attributes are sorted at construction by StatifierBlocks.Emission.element/3.

The guarantee is not reversible and must not be read as one. Equal output does not imply equal input: a metadata-only edit changes the document hash and produces identical SCXML, because metadata is not compiled. A host may use "same triple" to skip a recompile, and may not use "same SCXML" to conclude the document is unchanged.

Summary

Types

:known_invoke_types enables decision 8's optional two-registry lint; :entry_type is ADR-0003 decision 4's caller-supplied context; :datamodel is the host's declared datamodel, read only by the sensitive-path refusal; :declare is the <data> roots the host declares for this document. See the moduledoc.

Functions

Compiles document against palette.

Decision 6's third determinism input: this package's version.

Types

option()

@type option() ::
  {:known_invoke_types, Enumerable.t()}
  | {:entry_type, StatifierBlocks.Assignability.type_expr() | :unknown}
  | {:datamodel, term()}
  | {:child_use, boolean()}
  | {:terminate, boolean()}
  | {:declare, [StatifierBlocks.Compiler.DeclaredRoots.declaration()]}

:known_invoke_types enables decision 8's optional two-registry lint; :entry_type is ADR-0003 decision 4's caller-supplied context; :datamodel is the host's declared datamodel, read only by the sensitive-path refusal; :declare is the <data> roots the host declares for this document. See the moduledoc.

Functions

compile(document, palette, opts \\ [])

Compiles document against palette.

Total: {:ok, %StatifierBlocks.Compiled{}} or {:error, [%StatifierBlocks.Compiler.Finding{}]}, never a raise and never a partial success. Errors come from the first failing stage only (decision 10); warnings ride on the artifact when the compile succeeds.

compiler_version()

@spec compiler_version() :: String.t()

Decision 6's third determinism input: this package's version.