Immutable state diagrams

moonrockz/diagrams/state stores state diagrams without a syntax reader, renderer, host adapter or UI dependency. Constructors copy arrays. State IDs, region IDs, note IDs and transition IDs are distinct identities within a diagram. Equal labels and parallel transitions keep separate IDs.

///|
test "construct nested concurrent regions and preserve their order" {
  let states = [
    @state.State::new("Session", role=Composite),
    @state.State::new("Idle", region="control", descriptions=["entry / log"]),
    @state.State::new("History", region="control", role=DeepHistory),
    @state.State::new("Listening", region="network"),
  ]
  let view = @state.StateDiagram::new(
    "session",
    states,
    [
      @state.Transition::new(
        "remember",
        "Idle",
        "History",
        label="resume [ready] / log",
      ),
    ],
    regions=[
      @state.Region::new("root"),
      @state.Region::new("control", owner="Session"),
      @state.Region::new("network", owner="Session", separator=Vertical),
    ],
    notes=[
      @state.Note::new(
        "detail",
        "Description only",
        targets=["Idle"],
        region="control",
      ),
    ],
  )
  states.clear()
  assert_eq(view.states.length(), 4)
  assert_eq(view.regions[1].members, ["Idle", "History"])
  assert_eq(view.regions[2].separator, Vertical)
  assert_eq(@state.validate(view), [])
  assert_eq(view.with_direction(RL).transitions, view.transitions)
}

Every state has one owning region. Each non-root region names a composite owner. A composite can own several ordered concurrent regions or an empty region. Region.members preserves member order. When a caller omits it, construction fills it from state order and State.region. Explicit membership must agree with those assignments. Horizontal and Vertical retain separator orientation. A composite's expanded=false hides descendants in the rendered view, while the source model and semantic transition endpoints stay intact.

Role distinguishes Simple, Composite, Initial, Final, Choice, Fork, Join, ShallowHistory, DeepHistory, EntryPoint, ExitPoint, InputPin, OutputPin, ExpansionInput and ExpansionOutput. Unsupported(symbol) retains a source symbol for explicit renderer refusal. History retains its owning region and does not restore a prior state. Description lines retain entry, do, exit and internal behavior as ordered text. They never add transitions.

Transitions retain declared source and target, ID, label, line style, source and target markers, minimum length, direction hint and optional UTF-16 source binding. Directions and marker types use the existing pure flowchart value types. Global layout direction does not reverse transition endpoints. Note.targets names states; Note.connection_targets names transitions for link notes. Neither kind of attachment adds a route edge.

validate checks duplicate IDs, missing endpoints, ownership cycles, missing owners, membership, note targets and source spans. It validates drawn structure, not executable UML semantics. The caller controls node, transition, region, depth, diagnostic and work limits. state/invalid-limits rejects invalid options. state/model-limit, state/depth-limit, state/diagnostic-limit and state/work-limit report exhausted budgets. A nonempty diagnostic array is a failed validation result. No repair or successful partial model is implied.

///|
test "reject invalid references and distinguish exhausted validation" {
  let view = @state.StateDiagram::new("invalid", [@state.State::new("A")], [
    @state.Transition::new("missing", "A", "B"),
  ])
  assert_true(
    @state.validate(view).any(d => d.code == "state/missing-endpoint"),
  )
  assert_eq(@state.validate(view, work_limit=1)[0].code, "state/work-limit")
  assert_eq(@state.fresh_id("A", ["A", "A:1"]).unwrap(), "A:2")
}

Pass Diagram::State(view) to a renderer. Source readers live in the Mermaid and PlantUML modules. Geometry, scenes, finite scenarios and queries remain separate APIs. All pure state operations run on wasm, wasm-gc, js and native. Run mise run test:state-consumer to verify fresh model, dedicated-reader and player consumers; append -- --published after release to use registry dependencies.

View source on GitHub