StatifierBlocks.Compiler (StatifierBlocks v0.31.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. A resolved node whose module is a composite is replaced, in place, by the subtree StatifierBlocks.Composite.expand!/2 returns (ADR-0004's amendment of 2026-09-07, E1), so stages 3-6 read a tree with no composite in it and need no knowledge that one was ever there. The param map the expansion returns is kept beside the tree, and a finding raised against a block inside an expansion is re-anchored to the composite block and the param that produced it before it is reported (E3). The provenance map is never rewritten: every span keeps the expanded block that emitted it.
  3. Config - every block's validate_config/1, the checks beside it that read something a block type cannot see from its own config, and the one name a root block may not declare as an outcome (ADR-0002's failure amendment of 2026-09-06, section 4 step 3, and the Note that closes it).
  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.

Config and Structure are reported together

Every other stage in decision 10's table stops the pipeline the moment it fails, and the errors a caller gets back come from that one stage. Config and Structure are the one pair that does not: when Config finds something, Structure still runs, and the refusal carries the union of what both found (RQ-SF035-2, and the dated Notes of 2026-09-06 on ADR-0004 decision 10 and on ADR-0011 decision 1).

The reason is that they are not in a consequence relation the way the later stages are. Emit reads the tree Structure has already agreed is well-formed, so its findings on a document Structure refused would be artefacts. Structure reads the document, not the config values Config checks, so a mis-typed field on one card and an unsatisfied read on another are two independent statements about the same document - and an author who can only see the first has to fix it, recompile, and discover the second, one round trip per stage. Decision 10's "every finding within a stage is reported, because they are siblings" is the same argument; this extends it across exactly the one stage boundary where it holds.

What a block whose config Config refused contributes to Structure is nothing at all - it is skipped by id, and every other block is checked exactly as it would have been. That is what the old sequencing bought and what has to be bought again some other way: every source in the Structure stage reads the refused config. A write signature comes off a config, so an entry derived from one would make the next block's read disagree with a type nobody declared; slots/1 takes a config too, so slot arity and :undeclared_slot on a refused block would be counted against a slot set that config does not really declare; and StatifierBlocks.Shelf places a block the config named. So the refused ids reach the environment walk as :skip_blocks - the block declares no read and writes no entry - and the two document-shape sources drop the findings they anchored on those same ids.

The walk itself continues past a skipped block: its siblings and its children are checked as they always were, and a child's config is its own. This is an absence of one block's answers, not a shortened stage.

Refusal semantics are unchanged. A document with a Config finding still does not compile; it now says more about why.

The Structure stage is whole

Decision 10's table names three things in this stage: slot arity, :undeclared_slot, and assignability. ADR-0004's amendment of 2026-08-31, section D3, adds two more to the same row under campaign-024 ruling R-b - :drafts_block_misplaced and :duplicate_drafts_block, the two placement facts a block type's io/1 cannot carry, owned by StatifierBlocks.Shelf. All of them 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.

  • :field_candidates - the values a host offers per field, keyed {type_name, field_key}, the same map the editor's own field_candidates assign takes. A closed list - [{value, label}] - turns a value it does not offer into a warning, never an error; an open list - {:open, choices} - reports nothing, and a field the map does not name is not looked at. Absent, the lint does not run.

  • :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.

    The same document is read by two other checks, and it is read once: the structure stage's typed-environment read check (ADR-0011 decisions 2 and 3), and the config stage's declared-payload refusal, where a core.on_event that declares its payload refuses a capture pair reading a member that payload does not carry (ADR-0002's amendment of 2026-09-06). A compile with no :datamodel runs neither, and refuses nothing.

  • :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.

    Beside those per-outcome finals, one shared top-level <final> is emitted when a block below the root declares a failure-classed outcome - one StatifierBlocks.BlockType.failure_outcomes/2 names - that the document did not handle (ADR-0002's failure amendment of 2026-09-06, section 4). It is minted from the root block's id under the role child_failed, one transition per unhandled pair reaches it from the root block's own state, and it carries <param name="outcome" expr="'error'"/> beside the reserved <param name="statifier_persistence:execution_status" expr="'failed'"/>. The outcome it reports is error rather than the failing block's own outcome name, because StatifierBlocks.Core.Subchart appends error to its outcomes whether or not the author listed it, so it is the one word a parent is guaranteed to have a route for. A document with no unhandled failure below its root gains nothing here, and a root block may not declare an outcome named failed: that would ask for this final's state id a second time, and it is refused as a Config-stage finding.

  • :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> - except that a final for a failure-classed outcome carries the one reserved run-status <param> described below - 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.

    Beside those per-outcome finals, one shared top-level <final> is emitted when a block below the root declares a failure-classed outcome the document did not handle, exactly as under :child_use (ADR-0002's failure amendment of 2026-09-06, section 4). Here it is minted from the root block's id under the role root_failed, one transition per unhandled pair reaches it from the root block's own state, and its <donedata> holds only the reserved <param name="statifier_persistence:execution_status" expr="'failed'"/> - the key statifier_persistence's ADR-0011 decision 4 (proposed, SF041) fixes, which a durable stepper reads to decide that the execution failed. So a root document whose nested step fails still reaches :done, and says that it failed when it gets there. A document with no unhandled failure below its root gains nothing here, and a root block may not declare an outcome named failed: that would ask for this final's state id a second time, and it is refused as a Config-stage finding.

    :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 it leads: the document has a second one, ADR-0001 decision 11's top-level datamodel key (added 2026-08-31), whose roots follow the host's in the single <datamodel> - :declare roots, then the document's own, then block-declared roots, all in document order. A root both declare is host-wins: the compile call's declaration is the one emitted, the document's is dropped, and the artifact carries a warning (:shadowed_document_root) rather than a refusal, since the compile call is what a host controls and the document edit that would silence the warning is not the one that fixes anything. No block type declares a root of its own - that surface is still untaken, ADR-0002's to take. Absent or [] emits no <datamodel> unless the document or a block declares a root, so a document compiled without the option 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.

    See StatifierBlocks.Compiler.DeclaredRoots's "Document-declared roots" section for the full precedence rule and for why a document root colliding with a block-declared root stays F6's :duplicate_binding error rather than becoming a second kind of warning.

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.

The document's datamodel key (ADR-0001 decision 11) is part of the document's canonical bytes and therefore of the triple's first input, so the guarantee is unweakened by its existence - moving an entry's id or expr moves the document hash and, because those are compiled, moves the generated SCXML too. But it is a second instance of the same non-reversibility clause above: an entry's description is prose, not compiled, so two documents differing only in a description hash differently and still produce byte-identical SCXML.

Summary

Types

:known_invoke_types enables decision 8's optional two-registry lint; :field_candidates enables the opt-in warning for a value outside a host's own closed candidate list; :entry_type is ADR-0003 decision 4's caller-supplied context; :datamodel is the host's declared datamodel, read by the sensitive-path refusal, the typed-environment read check and the declared-payload 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()}
  | {:field_candidates, %{optional({String.t(), String.t()}) => term()}}
  | {: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; :field_candidates enables the opt-in warning for a value outside a host's own closed candidate list; :entry_type is ADR-0003 decision 4's caller-supplied context; :datamodel is the host's declared datamodel, read by the sensitive-path refusal, the typed-environment read check and the declared-payload 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), with one exception the moduledoc's "Config and Structure are reported together" section states: those two stages run as a pair, and a refusal carries the union of their findings. 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.