StatifierBlocks.Compiler.DeclaredRoots (StatifierBlocks v0.13.0)

Copy Markdown View Source

How a block type contributes a compiler-declared <data> root to the chart's one top-level <datamodel> (ADR-0004's 2026-08-29 foreach amendment, F2 and F3), and the collision refusal F6 records.

Until core.foreach there was no such thing: every core type wrote into the datamodel the host had already declared, and the compiler emitted no <datamodel> at all. A loop cannot. Its cursor and its snapshot of the list are the compiler's own state, its item_as/index_as bindings are roots an author named, and predicator refuses to read a root nothing declared - so the four names have to be declared somewhere, and early binding means "somewhere" is the top of the document, before any state is entered.

The mechanism: declare in place, hoist once

A block type emits declare/2 elements among its own state's children, where everything else it emits already is, and the compiler lifts every one of them out of the assembled emission into a single top-level <datamodel> just before serialization (hoist/1).

Declaring in place rather than through a new StatifierBlocks.BlockType callback is what keeps this small. A callback would be a change to ADR-0002's declaration surface for one block type's benefit; a <data> element is something emit/2 can already return, it goes through StatifierBlocks.Compiler.Attribution on the ordinary path, and so it arrives here already carrying the owner and the config key a finding against it needs. Nothing about the block type's contract changes.

Determinism (decision 6)

hoist/1 walks the assembled tree in document order and emits the roots in the order it meets them, outermost first. The walk is over lists, in the order emit/2 built them, so the same document produces the same bytes - the property decision 6 requires and the reason no sort happens here. A document declaring no roots gets no <datamodel> element at all, so every chart this package compiled before this mechanism existed still compiles to the same bytes.

Provenance (decision 5)

A hoisted element is moved, never rebuilt: it carries the owner Attribution stamped on it, so the provenance map stays total over the bytes it becomes. The <datamodel> wrapper belongs to no block and is attributed to the root block, exactly as <scxml> is.

F6: :duplicate_binding

Early binding makes a declared root global, so a nested loop re-using an enclosing loop's item_as would overwrite the outer binding and the outer body would carry on with the inner loop's last item. The compiler refuses the document instead: a root declared inside the subtree of a block that already declares the same name is a :duplicate_binding finding, reported by StatifierBlocks.Compiler as an Emit-stage error against the block whose binding collides, carrying the config_key the name was typed into (item_as or index_as) and therefore the author's fault.

The rule this module applies is nesting, not the whole document - "collide across nesting", as F6 words it - and that is deliberately the narrow carve-out from decision 9's delegation rather than a general id-uniqueness check. Two roots of one name that are not nested are still refused, one stage later and by the delegated check: statifier's own id-uniqueness pass reports {:duplicate_id, name} over the two <data> elements, and StatifierBlocks.Compiler.Chart maps it back to the declaring block and its config field. F6 describes itself as pre-empting exactly that finding for the case where the overwrite would otherwise be silent, and pre-empting it anywhere else would be a widening of decision 9 this module has no mandate for.

Author-declared <data> ids, F6's other half, are covered by the same walk the day they exist: no config field declares one and no block type emits one for a name an author typed as an id. Nothing here special-cases core.foreach, so a declaration arriving as a <data> element is checked against the loops beneath it without this module being touched.

Host-declared roots (the :declare compile option)

That day arrived for the host rather than for the author. declarations/1 turns StatifierBlocks.Compiler.compile/3's :declare option - a list of {id, expr} pairs in declaration order - into the same declare/2 emissions a block type contributes, and the compiler prepends them to the root block's own children before hoist/1 runs. Three things follow from that placement rather than from any new code here:

  • Order. hoist/1 lifts an element's own declarations before it descends, so the host's roots lead the single <datamodel> in the order the option lists them and block-declared roots follow in document order.
  • Collision. A host root is in scope for everything beneath it, so a block declaring the same name is F6's :duplicate_binding, reported against that block and its config key exactly as a nested loop's collision is. A name repeated within the option list never reaches the walk: declarations/1 refuses it, because there is no block to name in a finding about a list the host wrote.
  • Nothing else. No <datamodel> is emitted for a document that declares nothing, option absent or [] alike, so every chart compiled before the option existed still compiles to the same bytes.

An id must be a bare lowercase identifier, the rule core.invoke applies to assign_to: the host is declaring a location the chart will assign to and read in a guard, and predicator's grammar is what both ends have to agree on. An expr is either nil, for a root that reads as undefined until something assigns it, or a non-empty expression written verbatim into the attribute. Run creation still wins over expr - a run seeded with a value for the id starts from that value (SCXML 5.3.2), which is upstream's behaviour and not this module's.

Document-declared roots (ADR-0001 decision 11)

The document itself carries a second declaration surface: a top-level datamodel key of StatifierBlocks.Document.DatamodelEntry structs, each already checked against ADR-0001's structural rules by StatifierBlocks.Validation before a document ever reaches the compiler. document_declarations/1 turns them into the same declare/2 emissions declarations/1 builds from the compile call's :declare option, and StatifierBlocks.Compiler prepends them to the root block's own children after the host's roots and before any block-declared root, which is what puts them second in the single <datamodel>:

  1. the compile call's :declare roots lead,
  2. the document's own datamodel roots follow,
  3. block-declared roots follow those, in document order.

Host wins. A document root whose id a host root already declares is dropped by shadowed/2 before the hoist ever runs, and the compiler turns the dropped id into one warning on the compiled artifact rather than a refusal - the compile call leads, so the host's declaration is the one that survives and the document's is silently fine to have, not silently fine to lose track of. This is different from F6: F6 is a refusal because an uncaught collision would silently overwrite a binding a chart still reads from two places, while a host-shadowed document root is never emitted at all, so there is nothing left in the chart to overwrite.

A document root colliding with a block-declared root is unchanged F6 :duplicate_binding, an error, through the same hoist/1 walk every other collision goes through: document roots are prepended among the root block's own children before the hoist runs, so every block in the document sits inside their scope exactly as it sits inside the host's. Nothing about hoist/1 needs to know a root came from the document rather than the host - the walk does not distinguish the two, and shadowed/2 is what keeps a host/document collision from ever reaching it as a false F6.

Summary

Types

One entry of the :declare compile option: a root id and either an initial expression or nil.

A refusal of the :declare option itself, before any walk: an entry that is not a well-formed declaration, or an id the list declares twice.

One collision: the block whose binding collides, the config key it was typed into (nil for a root no author named), and the offending name.

Functions

The top-level <datamodel> holding roots, or nothing at all when there are none.

The :declare compile option as declare/2 emissions, in the order the option lists them.

A <data> declaration for the root id, optionally with an initial expr.

The document's own datamodel entries as declare/2 emissions, in list order.

Lifts every <data> element out of emission, returning the stripped tree and the roots in document order.

Splits the document's own root emissions into the ones that survive and the ids a host root already declares.

Types

declaration()

@type declaration() :: {String.t(), String.t() | nil}

One entry of the :declare compile option: a root id and either an initial expression or nil.

declaration_finding()

@type declaration_finding() ::
  {:invalid_declaration, term()} | {:duplicate_declaration, String.t()}

A refusal of the :declare option itself, before any walk: an entry that is not a well-formed declaration, or an id the list declares twice.

finding()

@type finding() ::
  {:duplicate_binding, StatifierBlocks.Block.id() | nil, String.t() | nil,
   String.t()}

One collision: the block whose binding collides, the config key it was typed into (nil for a root no author named), and the offending name.

Functions

datamodel(roots)

The top-level <datamodel> holding roots, or nothing at all when there are none.

A list rather than a value, so the caller splices it into <scxml>'s children without a conditional - and so "no roots, no element" is this module's decision rather than every caller's.

declarations(declarations)

@spec declarations(term()) ::
  {:ok, [StatifierBlocks.Emission.t()]} | {:error, [declaration_finding()]}

The :declare compile option as declare/2 emissions, in the order the option lists them.

nil and [] are both "the host declares nothing" and produce no emissions, which is what keeps a document compiled without the option byte-identical to what it was before the option existed.

{:error, findings} when an entry is not a {id, expr} pair whose id is a bare lowercase identifier and whose expr is nil or a non-empty string, or when the list declares one id twice. Every entry is checked, so a host fixing its call sees all of them at once.

declare(id, expr \\ nil)

@spec declare(String.t(), String.t() | nil) :: StatifierBlocks.Emission.t()

A <data> declaration for the root id, optionally with an initial expr.

A root declared with no expr reads as undefined until something assigns it, which is what the loop's bindings want: item_as means nothing until the head state's first pass binds it.

document_declarations(entries)

@spec document_declarations([StatifierBlocks.Document.DatamodelEntry.t()]) :: [
  StatifierBlocks.Emission.t()
]

The document's own datamodel entries as declare/2 emissions, in list order.

Unlike declarations/1, this takes already-validated entries: the Document stage (StatifierBlocks.Document.validate/1, run before the compiler's Emit stage) refuses a document whose datamodel is malformed, so a %DatamodelEntry{} reaching here always has a bare identifier for an id and either nil or a non-empty expression for expr. That is why this returns a plain list rather than a tagged tuple: the shape is a schema rule, checked exactly once by StatifierBlocks.Validation, and re-checking it here would be a second place for that rule to drift from the one the document was actually validated against.

description is dropped - it is prose for a human reading the document, never compiled and never written into the emitted SCXML.

hoist(emission)

Lifts every <data> element out of emission, returning the stripped tree and the roots in document order.

{:error, findings} when a root is declared inside the subtree of a block that already declares the same name (F6).

shadowed(host, document)

Splits the document's own root emissions into the ones that survive and the ids a host root already declares.

Host wins: a document root whose id host also declares is dropped from kept_document_roots and its id collected into shadowed_ids, in document order. Both lists are otherwise document's own order, unchanged - this makes no claim about the host's own order, and does not need to, since the caller prepends host ahead of whatever this returns.