From c590aefe025a6b8b573d7503daae39b59cb6a771 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sat, 25 Apr 2026 10:01:47 +0200 Subject: [PATCH 1/8] fix: pair microflow splits with nearest merge Symptom: sequential if-without-else structures could be described with the continuation of the first split nested inside that split. Root cause: split/merge pairing selected an arbitrary common reachable merge from map iteration, so a later downstream merge could be chosen before the immediate branch convergence. Fix: rank common merge candidates by nearest branch distance, then total distance and ID, while ignoring non-normal flows for structural pairing. Tests: add unit coverage for nearest-merge selection and for keeping the continuation after a sequential split at top level. --- mdl/executor/cmd_microflows_show.go | 118 ++++++++++++++++--- mdl/executor/cmd_microflows_traverse_test.go | 115 ++++++++++++++++++ 2 files changed, 219 insertions(+), 14 deletions(-) diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index 55e26373..4356a4f5 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -874,26 +874,17 @@ func findMergeForSplit( flowsByOrigin map[model.ID][]*microflows.SequenceFlow, activityMap map[model.ID]microflows.MicroflowObject, ) model.ID { - flows := flowsByOrigin[splitID] + flows := findNormalFlows(flowsByOrigin[splitID]) if len(flows) < 2 { return "" } - // Follow each branch and collect all reachable nodes - branch0Nodes := collectReachableNodes(ctx, flows[0].DestinationID, flowsByOrigin, activityMap, make(map[model.ID]bool)) - branch1Nodes := collectReachableNodes(ctx, flows[1].DestinationID, flowsByOrigin, activityMap, make(map[model.ID]bool)) - - // Find the first common node that is an ExclusiveMerge - // This is a simplification - we look for the first merge point reachable from both branches - for nodeID := range branch0Nodes { - if branch1Nodes[nodeID] { - if _, ok := activityMap[nodeID].(*microflows.ExclusiveMerge); ok { - return nodeID - } - } + branchDistances := make([]map[model.ID]int, 0, len(flows)) + for _, flow := range flows { + branchDistances = append(branchDistances, collectReachableDistances(ctx, flow.DestinationID, flowsByOrigin, activityMap)) } - return "" + return selectNearestCommonMerge(activityMap, branchDistances) } // collectReachableNodes collects all nodes reachable from a starting node. @@ -923,4 +914,103 @@ func collectReachableNodes( return result } +// collectReachableDistances collects the shortest normal-flow distance from a +// branch start to every reachable node. Error handler flows are excluded because +// they do not participate in split/merge structural pairing. +func collectReachableDistances( + ctx *ExecContext, + startID model.ID, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + activityMap map[model.ID]microflows.MicroflowObject, +) map[model.ID]int { + _ = ctx + _ = activityMap + + distances := map[model.ID]int{} + type queueItem struct { + id model.ID + distance int + } + queue := []queueItem{{id: startID}} + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if previous, ok := distances[item.id]; ok && previous <= item.distance { + continue + } + distances[item.id] = item.distance + + for _, flow := range findNormalFlows(flowsByOrigin[item.id]) { + queue = append(queue, queueItem{ + id: flow.DestinationID, + distance: item.distance + 1, + }) + } + } + + return distances +} + +func selectNearestCommonMerge( + activityMap map[model.ID]microflows.MicroflowObject, + branchDistances []map[model.ID]int, +) model.ID { + if len(branchDistances) < 2 { + return "" + } + + type candidate struct { + id model.ID + maxDistance int + sumDistance int + } + candidates := []candidate{} + + for nodeID, firstDistance := range branchDistances[0] { + if _, ok := activityMap[nodeID].(*microflows.ExclusiveMerge); !ok { + continue + } + + maxDistance := firstDistance + sumDistance := firstDistance + common := true + for _, distances := range branchDistances[1:] { + distance, ok := distances[nodeID] + if !ok { + common = false + break + } + if distance > maxDistance { + maxDistance = distance + } + sumDistance += distance + } + if common { + candidates = append(candidates, candidate{ + id: nodeID, + maxDistance: maxDistance, + sumDistance: sumDistance, + }) + } + } + + if len(candidates) == 0 { + return "" + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].maxDistance != candidates[j].maxDistance { + return candidates[i].maxDistance < candidates[j].maxDistance + } + if candidates[i].sumDistance != candidates[j].sumDistance { + return candidates[i].sumDistance < candidates[j].sumDistance + } + return string(candidates[i].id) < string(candidates[j].id) + }) + + return candidates[0].id +} + // --- Executor method wrappers for callers in unmigrated code --- diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index ba60da11..f7512bd9 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -127,6 +127,53 @@ func TestTraverseFlow_IfElse(t *testing.T) { } } +func TestFindMergeForSplit_ChoosesNearestMergeBeforeDownstreamIf(t *testing.T) { + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("logo_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("logo_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Logo != empty"}, + }, + mkID("logo_then"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("logo_then")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "logo"}}}, + }, + mkID("logo_merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("logo_merge")}, + mkID("after_logo"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("after_logo")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "after logo"}}}, + }, + mkID("cover_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("cover_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Cover != empty"}, + }, + mkID("cover_then"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("cover_then")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "cover"}}}, + }, + mkID("cover_merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("cover_merge")}, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("logo_split"): { + mkBranchFlow("logo_split", "logo_then", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("logo_split", "logo_merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("logo_then"): {mkFlow("logo_then", "logo_merge")}, + mkID("logo_merge"): {mkFlow("logo_merge", "after_logo")}, + mkID("after_logo"): {mkFlow("after_logo", "cover_split")}, + mkID("cover_split"): { + mkBranchFlow("cover_split", "cover_then", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("cover_split", "cover_merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("cover_then"): {mkFlow("cover_then", "cover_merge")}, + } + + got := findMergeForSplit(nil, mkID("logo_split"), flowsByOrigin, activityMap) + if got != mkID("logo_merge") { + t.Fatalf("logo split paired with %q, want nearest merge %q", got, mkID("logo_merge")) + } +} + // TestTraverseFlow_IfWithoutElse verifies that when the FALSE branch jumps // straight to the merge point (as emitted by the builder for `if X then ... end if`), // the describer does not print an empty `else` — the previous behavior produced @@ -183,6 +230,74 @@ func TestTraverseFlow_IfWithoutElse(t *testing.T) { } } +func TestTraverseFlow_SequentialIfWithoutElseKeepsContinuationOutsideFirstIf(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("logo_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("logo_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Logo != empty"}, + }, + mkID("logo_then"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("logo_then")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "logo"}}}, + }, + mkID("logo_merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("logo_merge")}, + mkID("after_logo"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("after_logo")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "after logo"}}}, + }, + mkID("cover_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("cover_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Cover != empty"}, + }, + mkID("cover_then"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("cover_then")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "cover"}}}, + }, + mkID("cover_merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("cover_merge")}, + mkID("end"): µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "logo_split")}, + mkID("logo_split"): { + mkBranchFlow("logo_split", "logo_then", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("logo_split", "logo_merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("logo_then"): {mkFlow("logo_then", "logo_merge")}, + mkID("logo_merge"): {mkFlow("logo_merge", "after_logo")}, + mkID("after_logo"): {mkFlow("after_logo", "cover_split")}, + mkID("cover_split"): { + mkBranchFlow("cover_split", "cover_then", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("cover_split", "cover_merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("cover_then"): {mkFlow("cover_then", "cover_merge")}, + mkID("cover_merge"): {mkFlow("cover_merge", "end")}, + } + + splitMergeMap := map[model.ID]model.ID{ + mkID("logo_split"): findMergeForSplit(nil, mkID("logo_split"), flowsByOrigin, activityMap), + mkID("cover_split"): findMergeForSplit(nil, mkID("cover_split"), flowsByOrigin, activityMap), + } + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, splitMergeMap, visited, nil, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + firstEndIf := strings.Index(out, "end if;") + afterLogo := strings.Index(out, "after logo") + if firstEndIf == -1 || afterLogo == -1 || firstEndIf > afterLogo { + t.Fatalf("continuation after first IF was emitted inside the IF:\n%s", out) + } + for _, line := range lines { + if strings.Contains(line, "after logo") && strings.HasPrefix(line, " ") { + t.Fatalf("continuation after first IF must be top-level, got %q in:\n%s", line, out) + } + } +} + // ============================================================================= // collectErrorHandlerStatements // ============================================================================= From c47690113521ba62f1af2976b70fb3ad2013660e Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 03:10:40 +0200 Subject: [PATCH 2/8] test: add bug-test reproducer for nearest split-merge pairing fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an MDL script under mdl-examples/bug-tests/ exercising sequential if-without-else blocks. The describe → exec → describe fixpoint confirms the first split is paired with its nearest merge, keeping the ifs as siblings rather than nesting the continuation inside the first if's body. Co-Authored-By: Claude Opus 4.7 --- ...escriber-pair-split-with-nearest-merge.mdl | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 mdl-examples/bug-tests/326-describer-pair-split-with-nearest-merge.mdl diff --git a/mdl-examples/bug-tests/326-describer-pair-split-with-nearest-merge.mdl b/mdl-examples/bug-tests/326-describer-pair-split-with-nearest-merge.mdl new file mode 100644 index 00000000..da437382 --- /dev/null +++ b/mdl-examples/bug-tests/326-describer-pair-split-with-nearest-merge.mdl @@ -0,0 +1,55 @@ +-- ============================================================================ +-- Bug #326: Describer paired splits with downstream merge instead of nearest +-- ============================================================================ +-- +-- Symptom (before fix): +-- When a microflow had two sequential `if ... end if;` blocks, the +-- describer could pair the FIRST split with the SECOND merge, nesting +-- the continuation between the two ifs (and even the second if) inside +-- the first if's body. A describe → exec → describe cycle then mutated +-- the structure even though the original graph was valid: +-- -- before fix, after first describe: +-- if $Logo != empty then +-- log info node 'App' 'logo'; +-- log info node 'App' 'after logo'; -- wrong: was outside first if +-- if $Cover != empty then -- wrong: was sibling, not nested +-- log info node 'App' 'cover'; +-- end if; +-- end if; +-- +-- Root cause: +-- Split/merge pairing picked any common downstream merge reachable from +-- both branches, not the nearest one. Error-handler flows were included +-- in the search, biasing it further. +-- +-- After fix: +-- `findMergeForSplit` now uses shortest-path distances per branch and +-- selects the nearest common merge, ignoring error-handler flows for +-- structural pairing. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/326-describer-pair-split-with-nearest-merge.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest326.MF_SequentialIfs" +-- The describe output must keep the two `if ... end if;` blocks as +-- siblings (with the `log` between them at top level), and the +-- describe → exec → describe cycle must be a fixpoint. +-- ============================================================================ + +create module BugTest326; + +create microflow BugTest326.MF_SequentialIfs ( + $Logo: string, + $Cover: string +) +begin + if $Logo != empty then + log info node 'BugTest326' 'logo'; + end if; + + log info node 'BugTest326' 'after logo'; + + if $Cover != empty then + log info node 'BugTest326' 'cover'; + end if; +end; +/ From 89ba291c59a42b0654c829380d90421f0a42361d Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 08:29:38 +0200 Subject: [PATCH 3/8] refactor: drop unused collectReachableDistances parameters The ctx and activityMap parameters were carried over from a sibling helper but never used by the BFS traversal. Removes them and the matching `_ = ctx; _ = activityMap` discard lines, addressing AI review feedback on PR #327. Co-Authored-By: Claude Opus 4.7 --- mdl/executor/cmd_microflows_show.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index 4356a4f5..5d07266f 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -881,7 +881,7 @@ func findMergeForSplit( branchDistances := make([]map[model.ID]int, 0, len(flows)) for _, flow := range flows { - branchDistances = append(branchDistances, collectReachableDistances(ctx, flow.DestinationID, flowsByOrigin, activityMap)) + branchDistances = append(branchDistances, collectReachableDistances(flow.DestinationID, flowsByOrigin)) } return selectNearestCommonMerge(activityMap, branchDistances) @@ -918,14 +918,9 @@ func collectReachableNodes( // branch start to every reachable node. Error handler flows are excluded because // they do not participate in split/merge structural pairing. func collectReachableDistances( - ctx *ExecContext, startID model.ID, flowsByOrigin map[model.ID][]*microflows.SequenceFlow, - activityMap map[model.ID]microflows.MicroflowObject, ) map[model.ID]int { - _ = ctx - _ = activityMap - distances := map[model.ID]int{} type queueItem struct { id model.ID From f1a151a5ffd6e59f8e1ed614855d59a192ace675 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 10:38:36 +0200 Subject: [PATCH 4/8] fix: preserve terminal guard branch describe shape Symptom: describer could wrap a fall-through continuation in an empty or synthetic ELSE when the true branch terminated through nested or multi-activity flow instead of a direct EndEvent. Root cause: guard detection only checked whether the true branch destination was an EndEvent. It missed branches that reached a return through actions, nested IFs, enum splits, or inheritance splits. Fix: detect branch terminality recursively before the split merge boundary and use the guard form whenever the true branch terminates while the false branch starts the continuation path. Direct return-vs-return splits still keep the normal IF/ELSE shape. Tests: added traversal regressions for multi-activity terminal guards and nested terminal guards, while keeping existing sequential IF and IF/ELSE traversal coverage green. --- mdl/executor/cmd_microflows_show_helpers.go | 111 ++++++++++++++----- mdl/executor/cmd_microflows_traverse_test.go | 93 ++++++++++++++++ 2 files changed, 176 insertions(+), 28 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 3286eae3..98722ec8 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -528,21 +528,8 @@ func traverseFlow( *lines = append(*lines, indentStr+stmt) } - // Guard pattern: true branch is a single EndEvent (RETURN), - // but only when the false branch does NOT also end directly. - // If both branches return, use normal IF/ELSE/END IF. - isGuard := false - if trueFlow != nil { - if _, isEnd := activityMap[trueFlow.DestinationID].(*microflows.EndEvent); isEnd { - isGuard = true - // Not a guard if both branches return directly - if falseFlow != nil { - if _, falseIsEnd := activityMap[falseFlow.DestinationID].(*microflows.EndEvent); falseIsEnd { - isGuard = false - } - } - } - } + trueTerminates := branchFlowTerminatesBeforeMerge(trueFlow, mergeID, activityMap, flowsByOrigin, splitMergeMap) + isGuard := trueTerminates && !branchFlowStartsAtTerminal(falseFlow, activityMap) if isGuard { traverseFlowUntilMerge(ctx, trueFlow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) @@ -694,19 +681,8 @@ func traverseFlowUntilMerge( *lines = append(*lines, indentStr+stmt) } - // Guard pattern: true branch is a single EndEvent (RETURN), - // but only when the false branch does NOT also end directly. - isGuard := false - if trueFlow != nil { - if _, isEnd := activityMap[trueFlow.DestinationID].(*microflows.EndEvent); isEnd { - isGuard = true - if falseFlow != nil { - if _, falseIsEnd := activityMap[falseFlow.DestinationID].(*microflows.EndEvent); falseIsEnd { - isGuard = false - } - } - } - } + trueTerminates := branchFlowTerminatesBeforeMerge(trueFlow, nestedMergeID, activityMap, flowsByOrigin, splitMergeMap) + isGuard := trueTerminates && !branchFlowStartsAtTerminal(falseFlow, activityMap) if isGuard { traverseFlowUntilMerge(ctx, trueFlow.DestinationID, nestedMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) @@ -1165,6 +1141,85 @@ func findNormalFlows(flows []*microflows.SequenceFlow) []*microflows.SequenceFlo return result } +func branchFlowTerminatesBeforeMerge( + flow *microflows.SequenceFlow, + mergeID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + splitMergeMap map[model.ID]model.ID, +) bool { + if flow == nil { + return false + } + return objectTerminatesBeforeMerge(flow.DestinationID, mergeID, activityMap, flowsByOrigin, splitMergeMap, map[model.ID]bool{}) +} + +func branchFlowStartsAtTerminal(flow *microflows.SequenceFlow, activityMap map[model.ID]microflows.MicroflowObject) bool { + if flow == nil { + return false + } + switch activityMap[flow.DestinationID].(type) { + case *microflows.EndEvent, *microflows.ErrorEvent: + return true + default: + return false + } +} + +func objectTerminatesBeforeMerge( + currentID model.ID, + mergeID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + splitMergeMap map[model.ID]model.ID, + visited map[model.ID]bool, +) bool { + if currentID == "" || currentID == mergeID || visited[currentID] { + return false + } + visited[currentID] = true + + obj := activityMap[currentID] + switch obj.(type) { + case *microflows.EndEvent, *microflows.ErrorEvent: + return true + case *microflows.ExclusiveSplit, *microflows.InheritanceSplit: + nestedMergeID := splitMergeMap[currentID] + flows := findNormalFlows(flowsByOrigin[currentID]) + if len(flows) == 0 { + return false + } + for _, flow := range flows { + if !objectTerminatesBeforeMerge(flow.DestinationID, nestedMergeID, activityMap, flowsByOrigin, splitMergeMap, cloneVisited(visited)) { + return false + } + } + return true + case *microflows.ExclusiveMerge: + // A non-matching merge is just an intermediate join. Follow it; only the + // caller's mergeID is treated as the non-terminal fall-through boundary. + } + + flows := findNormalFlows(flowsByOrigin[currentID]) + if len(flows) == 0 { + return false + } + for _, flow := range flows { + if !objectTerminatesBeforeMerge(flow.DestinationID, mergeID, activityMap, flowsByOrigin, splitMergeMap, cloneVisited(visited)) { + return false + } + } + return true +} + +func cloneVisited(visited map[model.ID]bool) map[model.ID]bool { + cloned := make(map[model.ID]bool, len(visited)) + for id, seen := range visited { + cloned[id] = seen + } + return cloned +} + // formatErrorHandlingSuffix returns the ON ERROR suffix for an activity based on its ErrorHandlingType. // Returns empty string if no special error handling. func formatErrorHandlingSuffix(errType microflows.ErrorHandlingType) string { diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index f7512bd9..453c03ef 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -298,6 +298,99 @@ func TestTraverseFlow_SequentialIfWithoutElseKeepsContinuationOutsideFirstIf(t * } } +func TestTraverseFlow_GuardBranchWithMultipleActivitiesKeepsContinuationOutsideElse(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$HasExistingData"}, + }, + mkID("then_log"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("then_log")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "terminal branch"}}}, + }, + mkID("then_return"): µflows.EndEvent{BaseMicroflowObject: mkObj("then_return")}, + mkID("tail_log"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("tail_log")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "shared tail"}}}, + }, + mkID("end"): µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "split")}, + mkID("split"): { + mkBranchFlow("split", "then_log", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("split", "tail_log", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("then_log"): {mkFlow("then_log", "then_return")}, + mkID("tail_log"): {mkFlow("tail_log", "end")}, + } + if !branchFlowTerminatesBeforeMerge(flowsByOrigin[mkID("split")][0], "", activityMap, flowsByOrigin, nil) { + t.Fatal("expected multi-activity true branch to be terminal") + } + + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, nil, visited, nil, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + if strings.Contains(out, "\nelse\n") { + t.Fatalf("terminal guard branch should not wrap continuation in ELSE:\n%s", out) + } + if strings.Index(out, "end if;") > strings.Index(out, "shared tail") { + t.Fatalf("shared tail must be emitted after the guard IF closes:\n%s", out) + } +} + +func TestTraverseFlow_NestedTerminalGuardBranchSuppressesEmptyOuterElse(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("outer_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("outer_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$NeedsValidation"}, + }, + mkID("inner_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("inner_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$IsValid"}, + }, + mkID("valid_return"): µflows.EndEvent{BaseMicroflowObject: mkObj("valid_return")}, + mkID("invalid_return"): µflows.EndEvent{BaseMicroflowObject: mkObj("invalid_return")}, + mkID("tail_log"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("tail_log")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "fallback tail"}}}, + }, + mkID("end"): µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "outer_split")}, + mkID("outer_split"): { + mkBranchFlow("outer_split", "inner_split", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("outer_split", "tail_log", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("inner_split"): { + mkBranchFlow("inner_split", "valid_return", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("inner_split", "invalid_return", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("tail_log"): {mkFlow("tail_log", "end")}, + } + + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, nil, visited, nil, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + if got := strings.Count(out, "\n else\n"); got != 1 { + t.Fatalf("expected only the nested IF ELSE, got %d ELSE blocks:\n%s", got, out) + } + if strings.Index(out, "end if;") > strings.Index(out, "fallback tail") { + t.Fatalf("fallback tail must be emitted after the outer guard IF closes:\n%s", out) + } +} + // ============================================================================= // collectErrorHandlerStatements // ============================================================================= From 5447c8a6fa4768eaf71f6cc98e7bb6ebd2044c2c Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 15:28:18 +0200 Subject: [PATCH 5/8] fix: keep common non-merge split joins outside branches Symptom: describe could indent a shared tail under one branch when two split branches converged directly on an activity or downstream split instead of an ExclusiveMerge. Root cause: split pairing only considered ExclusiveMerge objects, so traversal had no stop point for branches that rejoined at a regular object. Fix: choose the nearest common executable join, and continue from that object after emitting the enclosing split when the join is not an ExclusiveMerge. Tests: added a synthetic common-tail traversal regression and ran make test. --- mdl/executor/cmd_microflows_show.go | 19 ++++- mdl/executor/cmd_microflows_show_helpers.go | 77 ++++++++++++++++---- mdl/executor/cmd_microflows_traverse_test.go | 70 ++++++++++++++++++ 3 files changed, 146 insertions(+), 20 deletions(-) diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index 5d07266f..cc77025b 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -867,7 +867,9 @@ func findSplitMergePoints( return result } -// findMergeForSplit finds the ExclusiveMerge where branches from a split converge. +// findMergeForSplit finds the nearest node where branches from a split converge. +// Studio Pro models often converge directly on the next activity instead of an +// explicit ExclusiveMerge, so the join can be any executable microflow object. func findMergeForSplit( ctx *ExecContext, splitID model.ID, @@ -884,7 +886,7 @@ func findMergeForSplit( branchDistances = append(branchDistances, collectReachableDistances(flow.DestinationID, flowsByOrigin)) } - return selectNearestCommonMerge(activityMap, branchDistances) + return selectNearestCommonJoin(activityMap, branchDistances) } // collectReachableNodes collects all nodes reachable from a starting node. @@ -948,7 +950,7 @@ func collectReachableDistances( return distances } -func selectNearestCommonMerge( +func selectNearestCommonJoin( activityMap map[model.ID]microflows.MicroflowObject, branchDistances []map[model.ID]int, ) model.ID { @@ -964,7 +966,7 @@ func selectNearestCommonMerge( candidates := []candidate{} for nodeID, firstDistance := range branchDistances[0] { - if _, ok := activityMap[nodeID].(*microflows.ExclusiveMerge); !ok { + if !isSplitJoinCandidate(activityMap[nodeID]) { continue } @@ -1008,4 +1010,13 @@ func selectNearestCommonMerge( return candidates[0].id } +func isSplitJoinCandidate(obj microflows.MicroflowObject) bool { + switch obj.(type) { + case nil, *microflows.StartEvent, *microflows.EndEvent: + return false + default: + return true + } +} + // --- Executor method wrappers for callers in unmigrated code --- diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 98722ec8..5b339178 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -570,14 +570,7 @@ func traverseFlow( *lines = append(*lines, indentStr+"end if;") recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) - // Continue after the merge point - if mergeID != "" { - visited[mergeID] = true - nextFlows := flowsByOrigin[mergeID] - for _, flow := range nextFlows { - traverseFlow(ctx, flow.DestinationID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) - } - } + continueAfterSplitJoin(ctx, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) } return } @@ -723,14 +716,7 @@ func traverseFlowUntilMerge( *lines = append(*lines, indentStr+"end if;") recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) - // Continue after nested merge - if nestedMergeID != "" && nestedMergeID != mergeID { - visited[nestedMergeID] = true - nextFlows := flowsByOrigin[nestedMergeID] - for _, flow := range nextFlows { - traverseFlowUntilMerge(ctx, flow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) - } - } + continueAfterNestedSplitJoin(ctx, nestedMergeID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) } return } @@ -769,6 +755,65 @@ func traverseFlowUntilMerge( } } +func continueAfterSplitJoin( + ctx *ExecContext, + joinID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + flowsByDest map[model.ID][]*microflows.SequenceFlow, + splitMergeMap map[model.ID]model.ID, + visited map[model.ID]bool, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, + lines *[]string, + indent int, + sourceMap map[string]elkSourceRange, + headerLineCount int, + annotationsByTarget map[model.ID][]string, +) { + if joinID == "" { + return + } + if _, isMerge := activityMap[joinID].(*microflows.ExclusiveMerge); isMerge { + visited[joinID] = true + for _, flow := range flowsByOrigin[joinID] { + traverseFlow(ctx, flow.DestinationID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + } + return + } + traverseFlow(ctx, joinID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) +} + +func continueAfterNestedSplitJoin( + ctx *ExecContext, + joinID model.ID, + parentMergeID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + flowsByDest map[model.ID][]*microflows.SequenceFlow, + splitMergeMap map[model.ID]model.ID, + visited map[model.ID]bool, + entityNames map[model.ID]string, + microflowNames map[model.ID]string, + lines *[]string, + indent int, + sourceMap map[string]elkSourceRange, + headerLineCount int, + annotationsByTarget map[model.ID][]string, +) { + if joinID == "" || joinID == parentMergeID { + return + } + if _, isMerge := activityMap[joinID].(*microflows.ExclusiveMerge); isMerge { + visited[joinID] = true + for _, flow := range flowsByOrigin[joinID] { + traverseFlowUntilMerge(ctx, flow.DestinationID, parentMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) + } + return + } + traverseFlowUntilMerge(ctx, joinID, parentMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) +} + // traverseLoopBody traverses activities inside a loop body. // When sourceMap is non-nil, it also records line ranges for each activity node. func traverseLoopBody( diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index 453c03ef..75e357cf 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -230,6 +230,76 @@ func TestTraverseFlow_IfWithoutElse(t *testing.T) { } } +func TestTraverseFlow_CommonActivityJoinKeepsTailOutsideBranches(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("outer_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("outer_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Allowed"}, + }, + mkID("allowed_act"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("allowed_act")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "allowed branch"}}}, + }, + mkID("denied_act"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("denied_act")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "denied branch"}}}, + }, + mkID("tail_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("tail_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$FollowUp"}, + }, + mkID("tail_true"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("tail_true")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "follow true"}}}, + }, + mkID("tail_false"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("tail_false")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "follow false"}}}, + }, + mkID("end"): µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "outer_split")}, + mkID("outer_split"): { + mkBranchFlow("outer_split", "allowed_act", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("outer_split", "denied_act", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("allowed_act"): {mkFlow("allowed_act", "tail_split")}, + mkID("denied_act"): {mkFlow("denied_act", "tail_split")}, + mkID("tail_split"): { + mkBranchFlow("tail_split", "tail_true", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("tail_split", "tail_false", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("tail_true"): {mkFlow("tail_true", "end")}, + mkID("tail_false"): {mkFlow("tail_false", "end")}, + } + + joinID := findMergeForSplit(nil, mkID("outer_split"), flowsByOrigin, activityMap) + if joinID != mkID("tail_split") { + t.Fatalf("outer split paired with %q, want common tail activity %q", joinID, mkID("tail_split")) + } + + splitMergeMap := map[model.ID]model.ID{mkID("outer_split"): joinID} + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, splitMergeMap, visited, nil, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + firstEndIf := strings.Index(out, "end if;") + tailIf := strings.Index(out, "if $FollowUp then") + if firstEndIf == -1 || tailIf == -1 || firstEndIf > tailIf { + t.Fatalf("shared tail must be emitted after the outer IF closes:\n%s", out) + } + for _, line := range lines { + if strings.Contains(line, "if $FollowUp then") && strings.HasPrefix(line, " ") { + t.Fatalf("shared tail must be top-level, got %q in:\n%s", line, out) + } + } +} + func TestTraverseFlow_SequentialIfWithoutElseKeepsContinuationOutsideFirstIf(t *testing.T) { e := newTestExecutor() From 46ef6283b36f661d183b35a8419147c0f2830370 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 17:18:17 +0200 Subject: [PATCH 6/8] fix: avoid terminal guard classification across nested split fallthrough Symptom: a branch containing a nested inheritance split could be classified as a terminal guard even when one split case fell through to the parent merge. Describe then closed the parent if without emitting the shared continuation, producing a microflow with a missing return value. Root cause: terminal-branch analysis recursed into nested splits using only the nested split merge. When that nested split had no own merge, the analysis ignored the parent merge and followed the fallthrough branch all the way to a terminal end event. Fix: reuse the parent merge as the nested stop point when a nested split has no merge of its own. This keeps fallthrough branches non-terminal for guard detection. Tests: added a synthetic inheritance-split fallthrough regression for branchFlowTerminatesBeforeMerge. --- mdl/executor/cmd_microflows_show_helpers.go | 3 ++ mdl/executor/cmd_microflows_traverse_test.go | 41 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 5b339178..ddeac466 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1230,6 +1230,9 @@ func objectTerminatesBeforeMerge( return true case *microflows.ExclusiveSplit, *microflows.InheritanceSplit: nestedMergeID := splitMergeMap[currentID] + if nestedMergeID == "" { + nestedMergeID = mergeID + } flows := findNormalFlows(flowsByOrigin[currentID]) if len(flows) == 0 { return false diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index 75e357cf..229e309f 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -576,6 +576,47 @@ func TestTraverseFlow_AlreadyVisited(t *testing.T) { } } +func TestBranchFlowTerminatesBeforeMerge_InheritanceSplitFallsThroughParentMerge(t *testing.T) { + entityID := mkID("entity-specialized") + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("type_split"): µflows.InheritanceSplit{ + BaseMicroflowObject: mkObj("type_split"), + VariableName: "Input", + }, + mkID("set_value"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("set_value")}, + Action: µflows.ChangeVariableAction{VariableName: "Value", Value: "$Input/Value"}, + }, + mkID("error_log"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("error_log")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "no matching type"}}}, + }, + mkID("error_return"): µflows.EndEvent{ + BaseMicroflowObject: mkObj("error_return"), + ReturnValue: "empty", + }, + mkID("parent_merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("parent_merge")}, + mkID("tail_return"): µflows.EndEvent{ + BaseMicroflowObject: mkObj("tail_return"), + ReturnValue: "'ok'", + }, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("type_split"): { + mkBranchFlow("type_split", "set_value", µflows.InheritanceCase{EntityID: entityID}), + mkBranchFlow("type_split", "error_log", µflows.InheritanceCase{}), + }, + mkID("set_value"): {mkFlow("set_value", "parent_merge")}, + mkID("error_log"): {mkFlow("error_log", "error_return")}, + mkID("parent_merge"): {mkFlow("parent_merge", "tail_return")}, + } + parentBranch := mkFlow("outer_split", "type_split") + + if branchFlowTerminatesBeforeMerge(parentBranch, mkID("parent_merge"), activityMap, flowsByOrigin, nil) { + t.Fatal("inheritance split branch that falls through the parent merge must not be classified as terminal") + } +} + // ============================================================================= // traverseFlowWithSourceMap — verifies source map recording // ============================================================================= From 7ccab5dc090178a2fff6d0f452d535ab6d01f6cd Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 09:18:09 +0200 Subject: [PATCH 7/8] chore: remove dead collectReachableNodes helper The describer no longer references collectReachableNodes; selectNearestCommonMerge uses collectReachableDistances exclusively. Drop the unused helper to keep the file focused. Addresses ako's cleanup note on PR #327. Co-Authored-By: Claude Opus 4.7 --- mdl/executor/cmd_microflows_show.go | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index cc77025b..65db1fa7 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -889,33 +889,6 @@ func findMergeForSplit( return selectNearestCommonJoin(activityMap, branchDistances) } -// collectReachableNodes collects all nodes reachable from a starting node. -func collectReachableNodes( - ctx *ExecContext, - startID model.ID, - flowsByOrigin map[model.ID][]*microflows.SequenceFlow, - activityMap map[model.ID]microflows.MicroflowObject, - visited map[model.ID]bool, -) map[model.ID]bool { - result := make(map[model.ID]bool) - - var traverse func(id model.ID) - traverse = func(id model.ID) { - if visited[id] { - return - } - visited[id] = true - result[id] = true - - for _, flow := range flowsByOrigin[id] { - traverse(flow.DestinationID) - } - } - - traverse(startID) - return result -} - // collectReachableDistances collects the shortest normal-flow distance from a // branch start to every reachable node. Error handler flows are excluded because // they do not participate in split/merge structural pairing. From aa262d8db145172f8e6f08f6dc541eea700d8c1d Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 14:30:52 +0200 Subject: [PATCH 8/8] chore: apply gofmt after main merge --- mdl/executor/cmd_microflows_builder.go | 2 +- mdl/executor/cmd_microflows_format_action.go | 42 ++++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index bd1d2c52..d7013287 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -53,7 +53,7 @@ type flowBuilder struct { microflowsCacheLoaded bool nanoflowsCache []*microflows.Nanoflow nanoflowsCacheLoaded bool - manualLoopBackTarget model.ID + manualLoopBackTarget model.ID } // addError records a validation error during flow building. diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index dd9dc270..89e76296 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -351,30 +351,30 @@ func formatAction( stmt := fmt.Sprintf("retrieve $%s from %s", outputVar, entityName) - if dbSource.XPathConstraint != "" { - constraint := strings.TrimSpace(dbSource.XPathConstraint) - // XPath may contain multiple predicates like [a][b] or [a]\n[b]. - // Split them and join with MDL 'and' so the parser sees - // separate xpathConstraint nodes. - if strings.HasPrefix(constraint, "[") && strings.HasSuffix(constraint, "]") { - // Split on "][" boundary (possibly separated by \n literals), - // then re-wrap each predicate. - inner := constraint[1 : len(constraint)-1] - // Normalise real newlines between predicates: ]\n[ → ][ - inner = strings.ReplaceAll(inner, "]\n[", "][") - parts := strings.Split(inner, "][") - if len(parts) > 1 { - var wrapped []string - for _, p := range parts { - wrapped = append(wrapped, "["+strings.TrimSpace(p)+"]") + if dbSource.XPathConstraint != "" { + constraint := strings.TrimSpace(dbSource.XPathConstraint) + // XPath may contain multiple predicates like [a][b] or [a]\n[b]. + // Split them and join with MDL 'and' so the parser sees + // separate xpathConstraint nodes. + if strings.HasPrefix(constraint, "[") && strings.HasSuffix(constraint, "]") { + // Split on "][" boundary (possibly separated by \n literals), + // then re-wrap each predicate. + inner := constraint[1 : len(constraint)-1] + // Normalise real newlines between predicates: ]\n[ → ][ + inner = strings.ReplaceAll(inner, "]\n[", "][") + parts := strings.Split(inner, "][") + if len(parts) > 1 { + var wrapped []string + for _, p := range parts { + wrapped = append(wrapped, "["+strings.TrimSpace(p)+"]") + } + constraint = strings.Join(wrapped, "\n ") + } else { + constraint = parts[0] } - constraint = strings.Join(wrapped, "\n ") - } else { - constraint = parts[0] } + stmt += fmt.Sprintf("\n where %s", constraint) } - stmt += fmt.Sprintf("\n where %s", constraint) - } // Output SORT BY clause if present if len(dbSource.Sorting) > 0 {