Skip to main content

dataflow_rs/engine/
steps.rs

1//! The authored step grammar: how a workflow's `tasks` array is read.
2//!
3//! An element of `tasks` is either a [`Task`] or a [`TaskGroup`], and the
4//! parser flattens that tree into `Workflow::tasks` at deserialization time so
5//! the executor keeps walking a flat slice. This module owns both halves of
6//! that grammar:
7//!
8//! - `flatten` — the parser, which builds `Vec<Task>` and fails on the first
9//!   malformed element.
10//! - [`walk_authored_steps`] — a public walker over the *authored* JSON, which
11//!   never fails and yields every node with the coordinate the author typed.
12//!
13//! They live together deliberately. The group test and the depth cap are the
14//! two facts a downstream host would otherwise have to mirror, and keeping the
15//! parser and the walker in one file puts both users of those facts on screen
16//! for anyone who changes them.
17//!
18//! # Why a host needs the authored shape
19//!
20//! By the time a host holds a [`Workflow`](crate::Workflow), the tree is gone:
21//! `tasks` is flat and `Task::group_starts` is not part of the stable API. But
22//! a validation error, a lint finding or a dependency extraction has to point
23//! at `tasks[1].tasks[0].id` — the coordinate in the document the author
24//! actually wrote. That is what this walker provides.
25
26use super::task::{Task, TaskGroup};
27use serde::Deserialize;
28use serde::de::{Deserializer, Error as DeError};
29use serde_json::Value;
30
31/// Maximum group nesting the parser accepts.
32///
33/// Deeper than this is a generated-JSON accident rather than an authored
34/// control-flow shape, and the bound keeps the per-task `group_starts` vector
35/// trivially small.
36///
37/// Public so a host validating authored JSON reads the engine's real limit
38/// instead of copying the number. Depth counts *enclosing groups*: a top-level
39/// group is at depth 0, so groups are accepted at depths `0..MAX_GROUP_DEPTH`
40/// and a group at `MAX_GROUP_DEPTH` is rejected by the parser and reported as
41/// [`StepKind::TooDeep`] by the walker.
42pub const MAX_GROUP_DEPTH: usize = 8;
43
44/// Whether this authored step element parses as a task group.
45///
46/// The test is **presence of a `tasks` key, nothing else** — the same test the
47/// parser makes. In particular a `tasks` key holding a non-array is still a
48/// group, and a malformed one: the parser will reject it as a bad group rather
49/// than silently reading it as a task.
50///
51/// An element carrying neither `tasks` nor `function` is *not* a group, so a
52/// caller reports a broken task — which is what the parser's own diagnostic
53/// says (`missing field 'function'`).
54///
55/// ```
56/// use dataflow_rs::engine::steps::is_group;
57/// use serde_json::json;
58///
59/// assert!(is_group(&json!({"id": "g", "tasks": []})));
60/// assert!(
61///     is_group(&json!({"id": "g", "tasks": "oops"})),
62///     "presence of the key, not its type — this is a malformed group"
63/// );
64/// assert!(!is_group(&json!({"id": "t", "function": {"name": "map"}})));
65/// assert!(!is_group(&json!({"id": "t"})), "neither key: a broken task");
66/// assert!(!is_group(&json!("not even an object")));
67/// ```
68#[inline]
69pub fn is_group(step: &Value) -> bool {
70    step.get("tasks").is_some()
71}
72
73/// What an authored step element is.
74///
75/// Deliberately not `#[non_exhaustive]`: a caller matching on this is deciding
76/// how to report a node, and a fourth kind would need that decision revisited
77/// at every site.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum StepKind {
80    /// A task — or an element malformed enough that it is not a group either.
81    Leaf,
82    /// A task group. Its members follow it in the walk.
83    Group,
84    /// A group nested at or beyond [`MAX_GROUP_DEPTH`]. The parser rejects the
85    /// whole workflow here; the walker reports it and does **not** descend, so
86    /// nothing is silently truncated without a node to point at.
87    TooDeep,
88}
89
90/// One node of an authored `tasks` tree.
91#[derive(Debug, Clone)]
92pub struct AuthoredStep<'a> {
93    /// The coordinate the author typed, rooted at the workflow: `tasks[1]`,
94    /// `tasks[1].tasks[0]`. Append your own field segment to point at a
95    /// property — `format!("{}.id", step.path)`.
96    pub path: String,
97    /// The element itself, borrowed from the input.
98    pub node: &'a Value,
99    /// Whether this is a task, a group, or a group too deeply nested.
100    pub kind: StepKind,
101    /// Enclosing groups. `0` for a top-level element.
102    pub depth: usize,
103}
104
105/// Walk an authored `tasks` array, yielding every node with its path.
106///
107/// Traversal is document order, pre-order: a group is yielded before its
108/// members, so filtering to [`StepKind::Leaf`] reproduces the engine's
109/// flattened `Workflow::tasks` exactly, in order.
110///
111/// **This walker never fails.** Where `flatten` returns `Err` on the first
112/// malformed element, an empty group or an over-deep group, the walker yields
113/// those nodes so a validator can collect every violation in one pass. A
114/// `tasks` value that is not an array yields nothing at all — whether `tasks`
115/// is a non-empty array is a rule for the caller to report, not for this walk
116/// to fail on.
117///
118/// ```
119/// use dataflow_rs::engine::steps::{StepKind, walk_authored_steps};
120/// use serde_json::json;
121///
122/// let tasks = json!([
123///     {"id": "first", "function": {"name": "map", "input": {"mappings": []}}},
124///     {"id": "guard", "condition": true, "tasks": [
125///         {"id": "inner", "function": {"name": "map", "input": {"mappings": []}}}
126///     ]}
127/// ]);
128///
129/// let steps: Vec<_> = walk_authored_steps(&tasks).collect();
130/// let seen: Vec<(&str, StepKind)> =
131///     steps.iter().map(|s| (s.path.as_str(), s.kind)).collect();
132///
133/// assert_eq!(seen, vec![
134///     ("tasks[0]", StepKind::Leaf),
135///     ("tasks[1]", StepKind::Group),
136///     ("tasks[1].tasks[0]", StepKind::Leaf),
137/// ]);
138/// ```
139pub fn walk_authored_steps(tasks: &Value) -> AuthoredSteps<'_> {
140    walk_authored_steps_at(tasks, "tasks")
141}
142
143/// [`walk_authored_steps`] with the paths rooted at `prefix` instead of
144/// `tasks`.
145///
146/// A loop's `setup` is a second step list on the workflow, and an issue in it
147/// has to point at `loop.setup[1].tasks[0]` — the coordinate the author typed.
148/// `walk_authored_steps_at(setup, "loop.setup")` yields exactly that.
149///
150/// ```
151/// use dataflow_rs::engine::steps::walk_authored_steps_at;
152/// use serde_json::json;
153///
154/// let setup = json!([
155///     {"id": "claim", "function": {"name": "map", "input": {"mappings": []}}},
156///     {"id": "g", "tasks": [
157///         {"id": "read", "function": {"name": "map", "input": {"mappings": []}}}
158///     ]}
159/// ]);
160/// let paths: Vec<String> = walk_authored_steps_at(&setup, "loop.setup")
161///     .map(|s| s.path)
162///     .collect();
163/// assert_eq!(paths, ["loop.setup[0]", "loop.setup[1]", "loop.setup[1].tasks[0]"]);
164/// ```
165pub fn walk_authored_steps_at<'a>(steps: &'a Value, prefix: &str) -> AuthoredSteps<'a> {
166    AuthoredSteps {
167        stack: match steps.as_array() {
168            Some(items) => vec![Frame {
169                items,
170                idx: 0,
171                prefix: prefix.to_string(),
172                depth: 0,
173            }],
174            // Not an array: nothing to walk. The caller reports the shape.
175            None => Vec::new(),
176        },
177    }
178}
179
180/// One level of the walk: the array being iterated, how far through it we are,
181/// and the path prefix its elements hang off.
182struct Frame<'a> {
183    items: &'a [Value],
184    idx: usize,
185    prefix: String,
186    depth: usize,
187}
188
189/// Iterator returned by [`walk_authored_steps`].
190///
191/// Lazy, over an explicit stack rather than recursion, so nothing is allocated
192/// beyond each node's `path` and the stack itself — which is bounded by
193/// [`MAX_GROUP_DEPTH`].
194pub struct AuthoredSteps<'a> {
195    stack: Vec<Frame<'a>>,
196}
197
198impl<'a> Iterator for AuthoredSteps<'a> {
199    type Item = AuthoredStep<'a>;
200
201    fn next(&mut self) -> Option<Self::Item> {
202        loop {
203            let frame = self.stack.last_mut()?;
204            let Some(node) = frame.items.get(frame.idx) else {
205                // This level is exhausted; resume the one that opened it.
206                self.stack.pop();
207                continue;
208            };
209
210            let path = format!("{}[{}]", frame.prefix, frame.idx);
211            let depth = frame.depth;
212            frame.idx += 1;
213
214            if !is_group(node) {
215                return Some(AuthoredStep {
216                    path,
217                    node,
218                    kind: StepKind::Leaf,
219                    depth,
220                });
221            }
222
223            // A group at the cap is what the parser rejects. Report it and do
224            // not descend — the members are unreachable either way, and this
225            // gives the caller a node to point at instead of a silent gap.
226            if depth >= MAX_GROUP_DEPTH {
227                return Some(AuthoredStep {
228                    path,
229                    node,
230                    kind: StepKind::TooDeep,
231                    depth,
232                });
233            }
234
235            // Descend only into a well-formed `tasks` array. A `tasks` key
236            // holding anything else is still a group — a malformed one — and is
237            // reported as such with no members.
238            if let Some(children) = node.get("tasks").and_then(Value::as_array) {
239                self.stack.push(Frame {
240                    items: children,
241                    idx: 0,
242                    prefix: format!("{path}.tasks"),
243                    depth: depth + 1,
244                });
245            }
246
247            return Some(AuthoredStep {
248                path,
249                node,
250                kind: StepKind::Group,
251                depth,
252            });
253        }
254    }
255}
256
257/// The non-`tasks` half of a group element. `tasks` is carried too so the
258/// whole element deserializes in one pass; unknown keys are ignored, as
259/// everywhere else in the workflow schema — with exactly one exception.
260///
261/// **`halt_on` is that exception, deliberately.** It is a per-*task* outcome
262/// rule and a group has no outcome of its own, so the executor could not honour
263/// it. Ignoring it would mean an author writing `"halt_on": "failure"` on a
264/// group gets silence and ships a guard that never fires — the precise failure
265/// this flag exists to prevent, so it is refused in `walk` instead. The cost of
266/// the exception is that a host using `halt_on` as its own annotation on a group
267/// node now fails to parse; `Workflow::validate_authored` reports it as
268/// `INVALID_HALT_ON` with the authored path, so the audit is mechanical.
269///
270/// `continue_on_error` is captured too, but **not** refused. It is the same
271/// class of mistake — a control-flow key the executor cannot honour — and the
272/// difference is age. `halt_on` was new, with no installed base to break;
273/// `continue_on_error` is real on both a [`Task`] and a `Workflow`, which is
274/// what makes a group the one place it looks like it should work, and a host
275/// may already carry it on group nodes. Refusing it would fail `Engine::build`,
276/// which aborts every workflow in that build. So it is recorded on
277/// [`TaskGroup::continue_on_error`] and reported by `check_workflow` as
278/// `GROUP_CONTINUE_ON_ERROR` instead of being refused or silently dropped.
279#[derive(Deserialize)]
280struct GroupHeader {
281    id: String,
282    #[serde(default)]
283    name: Option<String>,
284    #[serde(default)]
285    description: Option<String>,
286    #[serde(default = "crate::engine::utils::default_condition")]
287    condition: Value,
288    #[serde(default)]
289    terminal: bool,
290    /// Captured only so it can be refused — see the type-level note above.
291    /// Typed as `Option<Value>` rather than `Option<HaltOn>` so that *any*
292    /// shape is caught: a group carrying `halt_on` is wrong whatever its value.
293    #[serde(default)]
294    halt_on: Option<Value>,
295    /// Captured only so `check_workflow` can report it — see the type-level
296    /// note above. `Option<Value>` rather than `Option<bool>` for `halt_on`'s
297    /// reason inverted: a typed field would make `"continue_on_error": "yes"`
298    /// on a group a *parse error*, refusing a definition that loads today.
299    /// Only a literal `true` states an intent the engine defeats.
300    #[serde(default)]
301    continue_on_error: Option<Value>,
302    /// Captured only so it can be refused, like `halt_on`: a group has no
303    /// function of its own to fan out, and the key is new, so no installed
304    /// base carries it. Any shape is refused.
305    #[serde(default)]
306    for_each: Option<Value>,
307    tasks: Vec<Value>,
308}
309
310/// `deserialize_with` target for `Workflow::tasks`.
311///
312/// Fails fast, unlike [`walk_authored_steps`]: the engine will not run a
313/// workflow it cannot fully parse, so the first malformed element ends the
314/// attempt. A host that wants every problem at once walks the authored JSON
315/// instead.
316pub(crate) fn flatten<'de, D>(deserializer: D) -> Result<Vec<Task>, D::Error>
317where
318    D: Deserializer<'de>,
319{
320    let steps = Vec::<Value>::deserialize(deserializer)?;
321    let mut tasks = Vec::with_capacity(steps.len());
322    walk(&steps, 0, &mut tasks).map_err(D::Error::custom)?;
323    Ok(tasks)
324}
325
326/// Append `steps` to `out` in document order, recording group spans.
327fn walk(steps: &[Value], depth: usize, out: &mut Vec<Task>) -> Result<(), String> {
328    for step in steps {
329        if !is_group(step) {
330            let task: Task = serde_json::from_value(step.clone())
331                .map_err(|e| format!("invalid task in workflow tasks: {e}"))?;
332            out.push(task);
333            continue;
334        }
335
336        if depth >= MAX_GROUP_DEPTH {
337            return Err(format!(
338                "task groups nested deeper than {MAX_GROUP_DEPTH} levels"
339            ));
340        }
341
342        let header: GroupHeader = serde_json::from_value(step.clone())
343            .map_err(|e| format!("invalid task group in workflow tasks: {e}"))?;
344
345        if header.halt_on.is_some() {
346            return Err(format!(
347                "task group '{}' cannot carry halt_on — halt_on is a per-task \
348                 outcome rule; put it on the task that can fail",
349                header.id
350            ));
351        }
352
353        if header.for_each.is_some() {
354            return Err(format!(
355                "task group '{}' cannot carry for_each — for_each runs one task's function \
356                 once per element; put it on the task",
357                header.id
358            ));
359        }
360
361        let start = out.len();
362        walk(&header.tasks, depth + 1, out)?;
363        let end = out.len();
364        if end == start {
365            return Err(format!(
366                "task group '{}' contains no tasks — an empty group can only be a mistake",
367                header.id
368            ));
369        }
370
371        // Outermost first: an inner group nested at the same start index
372        // has already pushed its own entry, so this one goes in front of
373        // it. Bounded by `MAX_GROUP_DEPTH`, so the shift is trivial.
374        out[start].group_starts.insert(
375            0,
376            TaskGroup {
377                id: header.id,
378                name: header.name,
379                description: header.description,
380                condition: header.condition,
381                compiled_condition: None,
382                terminal: header.terminal,
383                continue_on_error: matches!(header.continue_on_error, Some(Value::Bool(true))),
384                end,
385            },
386        );
387    }
388    Ok(())
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::engine::workflow::Workflow;
395    use serde_json::json;
396
397    fn leaf(id: &str) -> Value {
398        json!({"id": id, "name": id, "function": {"name": "map", "input": {"mappings": []}}})
399    }
400
401    /// `n` groups nested one inside the next, innermost holding one task.
402    /// `n == 1` is a single top-level group, which sits at depth 0.
403    fn nested_groups(n: usize) -> Value {
404        let mut node = leaf("innermost");
405        for level in (0..n).rev() {
406            node = json!({"id": format!("g{level}"), "condition": true, "tasks": [node]});
407        }
408        json!([node])
409    }
410
411    fn workflow_with(tasks: &Value) -> Result<Workflow, String> {
412        Workflow::from_json(
413            &json!({"id": "w", "name": "w", "priority": 0, "tasks": tasks}).to_string(),
414        )
415        .map_err(|e| e.to_string())
416    }
417
418    fn kinds(tasks: &Value) -> Vec<(String, StepKind, usize)> {
419        walk_authored_steps(tasks)
420            .map(|s| (s.path, s.kind, s.depth))
421            .collect()
422    }
423
424    /// Acceptance criterion: the walker's leaf set is the parser's flattened
425    /// `Workflow::tasks`, by id and by order. This is what pins the two
426    /// recursions to each other — sharing `is_group` alone would not catch a
427    /// divergence in the walk itself.
428    #[test]
429    fn walker_leaves_match_the_parsers_flattened_tasks() {
430        let fixtures = vec![
431            json!([leaf("a"), leaf("b")]),
432            json!([{"id": "g", "condition": true, "tasks": [leaf("a"), leaf("b")]}]),
433            json!([
434                leaf("before"),
435                {"id": "g1", "condition": true, "tasks": [
436                    leaf("in1"),
437                    {"id": "g2", "condition": true, "tasks": [leaf("deep")]},
438                    leaf("in2"),
439                ]},
440                leaf("after"),
441            ]),
442            nested_groups(MAX_GROUP_DEPTH),
443        ];
444
445        for tasks in fixtures {
446            let parsed = workflow_with(&tasks).expect("fixture parses");
447            let from_parser: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
448
449            let from_walker: Vec<&str> = walk_authored_steps(&tasks)
450                .filter(|s| s.kind == StepKind::Leaf)
451                .map(|s| s.node["id"].as_str().unwrap())
452                .collect();
453
454            assert_eq!(
455                from_walker, from_parser,
456                "walker leaves must equal the flattened tasks, in order, for {tasks}"
457            );
458        }
459    }
460
461    #[test]
462    fn paths_are_the_coordinates_the_author_typed() {
463        let tasks = json!([
464            leaf("first"),
465            {"id": "g", "condition": true, "tasks": [leaf("inner"), leaf("second")]},
466        ]);
467
468        let paths: Vec<String> = walk_authored_steps(&tasks).map(|s| s.path).collect();
469        assert_eq!(
470            paths,
471            vec![
472                "tasks[0]",
473                "tasks[1]",
474                "tasks[1].tasks[0]",
475                "tasks[1].tasks[1]"
476            ]
477        );
478    }
479
480    #[test]
481    fn groups_are_yielded_before_their_members() {
482        let tasks = json!([{"id": "g", "condition": true, "tasks": [leaf("inner")]}]);
483        assert_eq!(
484            kinds(&tasks),
485            vec![
486                ("tasks[0]".to_string(), StepKind::Group, 0),
487                ("tasks[0].tasks[0]".to_string(), StepKind::Leaf, 1),
488            ],
489            "pre-order, so filtering to Leaf reproduces parse order"
490        );
491    }
492
493    #[test]
494    fn max_group_depth_is_the_value_the_parser_enforces() {
495        // Exactly at the cap parses: MAX_GROUP_DEPTH groups occupy depths
496        // 0..MAX_GROUP_DEPTH.
497        let ok = nested_groups(MAX_GROUP_DEPTH);
498        assert!(
499            workflow_with(&ok).is_ok(),
500            "{MAX_GROUP_DEPTH} levels of nesting is accepted"
501        );
502        assert!(
503            walk_authored_steps(&ok).all(|s| s.kind != StepKind::TooDeep),
504            "and the walker agrees nothing is too deep"
505        );
506
507        // One more is rejected by the parser…
508        let too_deep = nested_groups(MAX_GROUP_DEPTH + 1);
509        let err = workflow_with(&too_deep).expect_err("one level past the cap is rejected");
510        assert!(
511            err.contains("nested deeper than"),
512            "parser reports the depth cap, got: {err}"
513        );
514
515        // …and reported — not silently dropped — by the walker, at the same node.
516        let flagged: Vec<_> = walk_authored_steps(&too_deep)
517            .filter(|s| s.kind == StepKind::TooDeep)
518            .collect();
519        assert_eq!(flagged.len(), 1, "exactly the one offending group");
520        assert_eq!(flagged[0].depth, MAX_GROUP_DEPTH);
521        assert_eq!(flagged[0].node["id"], json!(format!("g{MAX_GROUP_DEPTH}")));
522    }
523
524    #[test]
525    fn a_too_deep_group_is_not_descended_into() {
526        let tasks = nested_groups(MAX_GROUP_DEPTH + 1);
527        let deepest = walk_authored_steps(&tasks).map(|s| s.depth).max().unwrap();
528        assert_eq!(
529            deepest, MAX_GROUP_DEPTH,
530            "the walk stops at the offending group; its members are never yielded"
531        );
532        assert!(
533            !walk_authored_steps(&tasks).any(|s| s.node["id"] == json!("innermost")),
534            "the leaf below the cap is unreachable, and reported as such by its absent parent"
535        );
536    }
537
538    #[test]
539    fn a_leaf_is_never_too_deep() {
540        // A task sitting inside the maximum legal nesting is fine — the parser
541        // checks depth only when it opens a group.
542        let tasks = nested_groups(MAX_GROUP_DEPTH);
543        let innermost = walk_authored_steps(&tasks)
544            .find(|s| s.node["id"] == json!("innermost"))
545            .expect("the deepest leaf is yielded");
546        assert_eq!(innermost.kind, StepKind::Leaf);
547        assert_eq!(innermost.depth, MAX_GROUP_DEPTH);
548    }
549
550    #[test]
551    fn an_element_with_neither_tasks_nor_function_is_a_leaf() {
552        // Reported as a broken *task*, matching the parser's own diagnostic —
553        // not as a broken group.
554        let tasks = json!([{"id": "orphan"}]);
555        assert_eq!(
556            kinds(&tasks),
557            vec![("tasks[0]".to_string(), StepKind::Leaf, 0)]
558        );
559
560        let err = workflow_with(&tasks).expect_err("the parser rejects it");
561        assert!(
562            err.contains("invalid task in workflow tasks"),
563            "and calls it a task, got: {err}"
564        );
565    }
566
567    #[test]
568    fn a_tasks_key_that_is_not_an_array_is_still_a_group() {
569        // Presence of the key decides, not its type. This is exactly where the
570        // TypeScript `isTaskGroup` used to disagree with the engine.
571        let tasks = json!([{"id": "g", "tasks": "oops"}]);
572        assert!(is_group(&tasks[0]));
573        assert_eq!(
574            kinds(&tasks),
575            vec![("tasks[0]".to_string(), StepKind::Group, 0)],
576            "a malformed group with no members, not a task"
577        );
578
579        let err = workflow_with(&tasks).expect_err("the parser rejects it");
580        assert!(
581            err.contains("invalid task group"),
582            "and calls it a group, got: {err}"
583        );
584    }
585
586    #[test]
587    fn an_empty_group_is_yielded_not_an_error() {
588        // The walker is total where the parser fails fast, so a validator can
589        // collect this alongside every other violation in one pass.
590        let tasks = json!([{"id": "empty", "condition": true, "tasks": []}]);
591        assert_eq!(
592            kinds(&tasks),
593            vec![("tasks[0]".to_string(), StepKind::Group, 0)]
594        );
595
596        let err = workflow_with(&tasks).expect_err("the parser rejects an empty group");
597        assert!(err.contains("contains no tasks"), "got: {err}");
598    }
599
600    #[test]
601    fn a_non_array_input_yields_nothing() {
602        for input in [
603            Value::Null,
604            json!({}),
605            json!("tasks"),
606            json!(7),
607            json!({"tasks": []}),
608        ] {
609            assert_eq!(
610                walk_authored_steps(&input).count(),
611                0,
612                "not an array, so nothing to walk: {input}"
613            );
614        }
615    }
616
617    #[test]
618    fn a_prefixed_walk_roots_every_path_at_the_prefix() {
619        let setup = json!([
620            leaf("claim"),
621            {"id": "g", "condition": true, "tasks": [leaf("batch")]},
622        ]);
623        let paths: Vec<String> = walk_authored_steps_at(&setup, "loop.setup")
624            .map(|s| s.path)
625            .collect();
626        assert_eq!(
627            paths,
628            vec!["loop.setup[0]", "loop.setup[1]", "loop.setup[1].tasks[0]"]
629        );
630        // The unprefixed walker is the `tasks` spelling of the same walk.
631        let plain: Vec<String> = walk_authored_steps(&setup).map(|s| s.path).collect();
632        assert_eq!(plain, vec!["tasks[0]", "tasks[1]", "tasks[1].tasks[0]"]);
633    }
634
635    #[test]
636    fn an_empty_array_yields_nothing_and_leaves_no_frame_behind() {
637        assert_eq!(walk_authored_steps(&json!([])).count(), 0);
638        // Nested empties must not hang or double-yield the parent.
639        let tasks = json!([{"id": "g", "tasks": [{"id": "inner", "tasks": []}]}]);
640        assert_eq!(
641            kinds(&tasks),
642            vec![
643                ("tasks[0]".to_string(), StepKind::Group, 0),
644                ("tasks[0].tasks[0]".to_string(), StepKind::Group, 1),
645            ]
646        );
647    }
648}