feat(default)!: model default(...) as a real LCA-aware transition

`default(...)` is now a transition with the same semantics as any
event-driven transition. It fires immediately after the declaring
state's entries finish and its durings start, applying the standard
exit-to-LCA / enter-to-target algorithm. Three behavioral changes
follow from this unification:

* Optional. A state with children is no longer required to declare a
  `default(...)`. When omitted, the state itself is the resting
  innermost active state; substates are reachable only via explicit
  transitions. This is the "microwave Standby" pattern — Standby
  has substates like LowPower that you enter only on demand, and
  `=> Standby;` lands at Standby itself with no descent.

* Targets anywhere. The default target may be any state in the chart
  — a child (the classic descent case), a sibling, an ancestor,
  anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
  state as part of the default-fire transition; the same rule fires
  whether you wrote it for descent, redirection, or up-bounce.

* Cycle-checked at compile time. The default-edge graph (out-degree
  ≤ 1 per node) is rejected if it contains any cycle, including
  `default(self)`. `state A { default(A); }` and `A → B → A` chains
  fail to compile with a pinpoint error message naming the cycle.
  This guarantees default chains terminate at runtime.

Implementation:

* `validate.rs`: drop the "must be direct child" and "must have
  children" restrictions; add `validate_default_graph` (iterative
  three-color cycle detection via chain walk).

* `ir.rs`: keep the unresolved default ident through to
  `resolve_transitions` so non-child targets resolve against the
  global state-name map (the same map transition targets use).

* `codegen.rs`: precomputed `(from, to)` paths no longer fold default
  descent into the enter slice — each path is LCA-relative
  `(exit, enter)` ending exactly at the explicit target.
  `descend_defaults` (a parent-pointer walk) is replaced by
  `follow_defaults`, which fires real transitions through
  `apply_path` and emits `TransitionFired{Internal}` /
  `TransitionComplete` brackets per link. Up-transitions don't
  trigger default-following (nothing was freshly entered, so no
  state's default fires).

* `active_durings_per_innermost_state`: predicate widened to include
  composites without a default (they can now be the innermost active
  state, so any durings they declare must be raced when current).

BREAKING CHANGES:

* Journal format. Default-fires now emit `TransitionFired` (with
  `reason: TransitionReason::Internal`) and `TransitionComplete`
  brackets around their entry sequence. `Internal` was previously
  reserved and never emitted; it is now active. Tests that
  hand-build expected journal sequences must include these brackets
  around every default-fire.

* Validation. `default(<non-child>)` and `default` on a childless
  state previously raised compile-time errors. Both are now valid.
  Code relying on these as guard rails must adapt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.7 committed 2026-04-28 20:57:01 -06:00
1 parent 2e55484080
commit 3fc0d7c57d
30 files changed
+1356 -229

No files matched your search

+76 -34
View File
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
use crate::parse::{StateBody, StatechartInput};
pub fn validate(ir: &Ir) -> syn::Result<()> {
validate_default_graph(ir)?;
// Duplicate state names
let mut seen = HashMap::new();
for s in &ir.states {
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
}
}
// Children require default; default must be a direct child; no default allowed if no children.
if !s.children.is_empty() && s.default_child.is_none() {
return Err(syn::Error::new(
s.span,
format!(
"state `{}` has children but no `default` declaration",
s.name
),
));
}
// `default` is optional: a composite without a `default(...)` is a
// valid resting state — transitions targeting it land on the composite
// itself, with no descent. Substates of such a composite are reachable
// only via explicit transitions. (`default` must still be a direct
// child when declared; that's checked in `validate_parse_tree`.)
// duplicate transitions per trigger in this state, AND transition
// target (if present) must be declared after every action handler
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
Ok(())
}
/// Walk the default-edge graph and reject cycles. Each state has at most one
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
///
/// Cycles are rejected at compile time because at runtime, a default chain
/// fires as a sequence of immediate transitions on entry — a cycle would loop
/// forever, never returning control to the dispatcher.
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
let n = ir.states.len();
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
let mut color = vec![0u8; n];
for start in 0..n {
if color[start] != 0 {
continue;
}
let mut chain: Vec<usize> = Vec::new();
let mut cur = start;
loop {
if color[cur] == 2 {
// Joined a previously-cleared subgraph. Chain so far is fine.
break;
}
if color[cur] == 1 {
// Back-edge into the current chain — cycle.
let cycle_start = chain
.iter()
.position(|&x| x == cur)
.expect("gray node must be on current chain");
let cycle: Vec<String> = chain[cycle_start..]
.iter()
.map(|&id| ir.states[id].name.to_string())
.collect();
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
let err_span = ir.states[chain[cycle_start]]
.default_target_ident
.as_ref()
.map(|(_, s)| *s)
.unwrap_or(ir.states[chain[cycle_start]].span);
return Err(syn::Error::new(
err_span,
format!(
"default-transition cycle detected: {} \
(entering any state in this cycle would loop forever \
before reaching user code)",
pretty
),
));
}
color[cur] = 1;
chain.push(cur);
match ir.states[cur].default_child {
Some(next) => cur = next as usize,
None => break,
}
}
for &id in &chain {
color[id] = 2;
}
}
Ok(())
}
/// Additional checks that require the original parse tree (for counting duplicate
/// `default`/`terminate` with their spans, and the "default in childless state" /
/// "default not a direct child" / "terminate in non-root" errors).
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
let root_name = input.name.to_string();
validate_body(&input.body, true, &root_name)
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
));
}
}
if body.default_child.is_some() && body.children.is_empty() {
return Err(syn::Error::new(
body.default_span.unwrap(),
format!(
"`default` in state `{}` which has no child states",
state_name
),
));
}
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
// default must be a direct child
let is_child = body.children.iter().any(|c| c.name == *dc);
if !is_child {
return Err(syn::Error::new(
*dspan,
format!(
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
dc, state_name, dc, state_name
),
));
}
}
// default(...) is a transition that fires immediately after the
// declaring state's entries finish. It may be declared on any state
// (composite or leaf) and may target any state in the chart. A leaf
// with `default(T)` just transitions to T on every entry. The
// default-graph cycle check in `validate_default_graph` (run from the
// top-level `validate_parse_tree` over the lowered IR) ensures we
// cannot construct an infinite chain at startup.
for c in &body.children {
let cname = c.name.to_string();
validate_body(&c.body, false, &cname)?;