From bf557681da41db98bf8e825d99c6a5c1d52ef7f6 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 14:51:31 +0200 Subject: [PATCH 1/6] fix: preserve no-merge branch continuations Symptom: when an if/else did not need an ExclusiveMerge because one branch was terminal, the builder could expose the original split as the continuation point instead of the tail of the non-terminal branch. Root cause: addIfStatement only carried a deferred split case to the parent flow. It did not remember the actual tail activity, pending branch case, or anchor from the branch that continued without a merge. Fix: track the no-merge continuation endpoint while building each branch, including pending case and anchor state from nested compound statements, and hand that endpoint to the parent flow instead of the stale split. Tests: add a synthetic builder regression where the else branch continues after the then branch returns, proving the following statement is wired from the branch tail rather than from the split. --- .../cmd_microflows_builder_control.go | 141 +++++++++++++++--- .../cmd_microflows_builder_no_merge_test.go | 75 ++++++++++ 2 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 mdl/executor/cmd_microflows_builder_no_merge_test.go diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index e47ca4f7..51ed8d8c 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -106,6 +106,9 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { } thenStartX := splitX + SplitWidth + HorizontalSpacing/2 + var noMergeExitID model.ID + var noMergeExitCase string + var noMergeExitAnchor *ast.FlowAnchors if hasElseBody { // IF WITH ELSE: TRUE path horizontal (happy path), FALSE path below @@ -115,6 +118,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { var lastThenID model.ID var prevThenAnchor *ast.FlowAnchors + var pendingThenCase string + var pendingThenAnchor *ast.FlowAnchors for _, stmt := range s.ThenBody { thisAnchor := stmtOwnAnchor(stmt) actID := fb.addStatement(stmt) @@ -129,7 +134,17 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, trueBranchAnchor, branchDestinationAnchor(trueBranchAnchor, thisAnchor)) fb.flows = append(fb.flows, flow) } else { - flow := newHorizontalFlow(lastThenID, actID) + var flow *microflows.SequenceFlow + if pendingThenCase != "" { + flow = newHorizontalFlowWithCase(lastThenID, actID, pendingThenCase) + if pendingThenAnchor != nil { + prevThenAnchor = pendingThenAnchor + } + pendingThenCase = "" + pendingThenAnchor = nil + } else { + flow = newHorizontalFlow(lastThenID, actID) + } applyUserAnchors(flow, prevThenAnchor, thisAnchor) fb.flows = append(fb.flows, flow) } @@ -138,6 +153,10 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if fb.nextConnectionPoint != "" { lastThenID = fb.nextConnectionPoint fb.nextConnectionPoint = "" + pendingThenCase = fb.nextFlowCase + fb.nextFlowCase = "" + pendingThenAnchor = fb.nextFlowAnchor + fb.nextFlowAnchor = nil } else { lastThenID = actID } @@ -149,7 +168,15 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { // nextConnectionPoint/nextFlowCase, so we must not emit a dangling flow here. if !thenReturns && needMerge { if lastThenID != "" { - flow := newHorizontalFlow(lastThenID, mergeID) + var flow *microflows.SequenceFlow + if pendingThenCase != "" { + flow = newHorizontalFlowWithCase(lastThenID, mergeID, pendingThenCase) + if pendingThenAnchor != nil { + prevThenAnchor = pendingThenAnchor + } + } else { + flow = newHorizontalFlow(lastThenID, mergeID) + } applyUserAnchors(flow, prevThenAnchor, nil) fb.flows = append(fb.flows, flow) } else { @@ -170,6 +197,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { var lastElseID model.ID var prevElseAnchor *ast.FlowAnchors + var pendingElseCase string + var pendingElseAnchor *ast.FlowAnchors for _, stmt := range s.ElseBody { thisAnchor := stmtOwnAnchor(stmt) actID := fb.addStatement(stmt) @@ -182,7 +211,17 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, falseBranchAnchor, branchDestinationAnchor(falseBranchAnchor, thisAnchor)) fb.flows = append(fb.flows, flow) } else { - flow := newHorizontalFlow(lastElseID, actID) + var flow *microflows.SequenceFlow + if pendingElseCase != "" { + flow = newHorizontalFlowWithCase(lastElseID, actID, pendingElseCase) + if pendingElseAnchor != nil { + prevElseAnchor = pendingElseAnchor + } + pendingElseCase = "" + pendingElseAnchor = nil + } else { + flow = newHorizontalFlow(lastElseID, actID) + } applyUserAnchors(flow, prevElseAnchor, thisAnchor) fb.flows = append(fb.flows, flow) } @@ -191,6 +230,10 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if fb.nextConnectionPoint != "" { lastElseID = fb.nextConnectionPoint fb.nextConnectionPoint = "" + pendingElseCase = fb.nextFlowCase + fb.nextFlowCase = "" + pendingElseAnchor = fb.nextFlowAnchor + fb.nextFlowAnchor = nil } else { lastElseID = actID } @@ -203,10 +246,50 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if !elseReturns && needMerge { if lastElseID != "" { flow := newUpwardFlow(lastElseID, mergeID) + if pendingElseCase != "" { + flow.CaseValue = microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: pendingElseCase, + } + if pendingElseAnchor != nil { + prevElseAnchor = pendingElseAnchor + } + } applyUserAnchors(flow, prevElseAnchor, nil) fb.flows = append(fb.flows, flow) } } + if !needMerge { + if thenReturns && !elseReturns { + if lastElseID != "" { + noMergeExitID = lastElseID + noMergeExitCase = pendingElseCase + if pendingElseAnchor != nil { + noMergeExitAnchor = pendingElseAnchor + } else { + noMergeExitAnchor = prevElseAnchor + } + } else { + noMergeExitID = splitID + noMergeExitCase = "false" + noMergeExitAnchor = falseBranchAnchor + } + } else if elseReturns && !thenReturns { + if lastThenID != "" { + noMergeExitID = lastThenID + noMergeExitCase = pendingThenCase + if pendingThenAnchor != nil { + noMergeExitAnchor = pendingThenAnchor + } else { + noMergeExitAnchor = prevThenAnchor + } + } else { + noMergeExitID = splitID + noMergeExitCase = "true" + noMergeExitAnchor = trueBranchAnchor + } + } + } } else { // IF WITHOUT ELSE: FALSE path horizontal (happy path), TRUE path below // This keeps the "do nothing" path straight and the "do something" path below @@ -230,6 +313,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { var lastThenID model.ID var prevThenAnchor *ast.FlowAnchors + var pendingThenCase string + var pendingThenAnchor *ast.FlowAnchors for _, stmt := range s.ThenBody { thisAnchor := stmtOwnAnchor(stmt) actID := fb.addStatement(stmt) @@ -241,7 +326,17 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, trueBranchAnchor, branchDestinationAnchor(trueBranchAnchor, thisAnchor)) fb.flows = append(fb.flows, flow) } else { - flow := newHorizontalFlow(lastThenID, actID) + var flow *microflows.SequenceFlow + if pendingThenCase != "" { + flow = newHorizontalFlowWithCase(lastThenID, actID, pendingThenCase) + if pendingThenAnchor != nil { + prevThenAnchor = pendingThenAnchor + } + pendingThenCase = "" + pendingThenAnchor = nil + } else { + flow = newHorizontalFlow(lastThenID, actID) + } applyUserAnchors(flow, prevThenAnchor, thisAnchor) fb.flows = append(fb.flows, flow) } @@ -250,6 +345,10 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if fb.nextConnectionPoint != "" { lastThenID = fb.nextConnectionPoint fb.nextConnectionPoint = "" + pendingThenCase = fb.nextFlowCase + fb.nextFlowCase = "" + pendingThenAnchor = fb.nextFlowAnchor + fb.nextFlowAnchor = nil } else { lastThenID = actID } @@ -262,6 +361,15 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if !thenReturns && needMerge { if lastThenID != "" { flow := newUpwardFlow(lastThenID, mergeID) + if pendingThenCase != "" { + flow.CaseValue = microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: pendingThenCase, + } + if pendingThenAnchor != nil { + prevThenAnchor = pendingThenAnchor + } + } applyUserAnchors(flow, prevThenAnchor, nil) fb.flows = append(fb.flows, flow) } else { @@ -273,6 +381,11 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { fb.flows = append(fb.flows, flow) } } + if !needMerge { + noMergeExitID = splitID + noMergeExitCase = "false" + noMergeExitAnchor = falseBranchAnchor + } } // If both branches end with RETURN, the flow terminates here @@ -300,22 +413,12 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { fb.posX = max(afterSplit, afterBranch) } fb.posY = centerY - fb.nextConnectionPoint = splitID - // Tell parent to attach the case value on the next flow, and pass the - // matching branch anchor so @anchor(true: ..., false: ...) survives to - // the deferred splitID→nextActivity flow. - if hasElseBody { - if thenReturns { - fb.nextFlowCase = "false" - fb.nextFlowAnchor = falseBranchAnchor - } else { - fb.nextFlowCase = "true" - fb.nextFlowAnchor = trueBranchAnchor - } + if noMergeExitID != "" { + fb.nextConnectionPoint = noMergeExitID + fb.nextFlowCase = noMergeExitCase + fb.nextFlowAnchor = noMergeExitAnchor } else { - // IF without ELSE: false is the continuing path - fb.nextFlowCase = "false" - fb.nextFlowAnchor = falseBranchAnchor + fb.nextConnectionPoint = splitID } } diff --git a/mdl/executor/cmd_microflows_builder_no_merge_test.go b/mdl/executor/cmd_microflows_builder_no_merge_test.go new file mode 100644 index 00000000..583e4ab9 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_no_merge_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestBuildFlowGraph_NoMergeIfElseContinuesFromBranchTail(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "Done"}, + ThenBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}}, + }, + ElseBody: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "else branch"}}, + }, + }, + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "after branch"}}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"Done": "Boolean"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeBoolean}}) + + elseID := findLogActivityIDByMessage(t, oc, "else branch") + afterID := findLogActivityIDByMessage(t, oc, "after branch") + + if !hasSequenceFlow(oc.Flows, elseID, afterID) { + t.Fatal("continuing ELSE branch tail must connect to the following statement") + } + for _, flow := range oc.Flows { + if flow.DestinationID == afterID && flow.OriginID != elseID { + t.Fatalf("following statement must not be wired from stale split origin %q", flow.OriginID) + } + } +} + +func findLogActivityIDByMessage(t *testing.T, oc *microflows.MicroflowObjectCollection, message string) model.ID { + t.Helper() + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + action, ok := activity.Action.(*microflows.LogMessageAction) + if !ok || action.MessageTemplate == nil { + continue + } + if action.MessageTemplate.GetTranslation("en_US") == message { + return activity.ID + } + } + t.Fatalf("missing log activity %q", message) + return "" +} + +func hasSequenceFlow(flows []*microflows.SequenceFlow, originID, destinationID model.ID) bool { + for _, flow := range flows { + if flow.OriginID == originID && flow.DestinationID == destinationID { + return true + } + } + return false +} From 47d7b056e694a16e775f4df85d94fe813a4df971 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 18:26:45 +0200 Subject: [PATCH 2/6] test: add bug-test reproducer for no-merge branch continuation fix Adds an MDL script under mdl-examples/bug-tests/ exercising an IF with one terminal branch and one continuing branch (no merge). After exec, `mx check` reports 0 errors, confirming the parent flow attaches to the continuing branch tail rather than the split. Co-Authored-By: Claude Opus 4.7 --- .../351-no-merge-branch-continuation.mdl | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 mdl-examples/bug-tests/351-no-merge-branch-continuation.mdl diff --git a/mdl-examples/bug-tests/351-no-merge-branch-continuation.mdl b/mdl-examples/bug-tests/351-no-merge-branch-continuation.mdl new file mode 100644 index 00000000..a2184c61 --- /dev/null +++ b/mdl-examples/bug-tests/351-no-merge-branch-continuation.mdl @@ -0,0 +1,46 @@ +-- ============================================================================ +-- Bug #351 (part): No-merge branch continuation +-- ============================================================================ +-- +-- Symptom (before fix): +-- When an `if/else` had ONE terminal branch (e.g. `return`) and ONE +-- continuing branch, the builder did not need to insert an +-- `ExclusiveMerge`. In that no-merge shape the parent flow that came +-- after the IF was still being attached to the original split node +-- instead of the tail of the continuing branch. The resulting graph +-- re-described as if the post-IF activity belonged to the split, and +-- `mx check` could fail with CE0117 because the next activity had +-- ambiguous incoming flows. +-- +-- After fix: +-- When the merge is skipped, the parent flow is attached to the tail +-- of the continuing branch (not the split). Both branches with merges +-- and no-merge shapes are now handled symmetrically. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/351-no-merge-branch-continuation.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest351.MF_NoMergeIfElse" +-- `mx check` against the resulting MPR must report 0 errors and the +-- describe → exec → describe cycle must converge to a fixpoint. +-- ============================================================================ + +create module BugTest351; + +-- IF with one terminal branch (return) and one continuing branch (log). +-- The post-IF `log info ... 'after branch'` and the final `return false;` +-- must connect to the ELSE tail, not to the split. +create microflow BugTest351.MF_NoMergeIfElse ( + $Done: boolean +) +returns boolean as $Result +begin + if $Done then + return true; + else + log info node 'BugTest351' 'else branch'; + end if; + + log info node 'BugTest351' 'after branch'; + return false; +end; +/ From d73144c6d43eb31d5e568797d38e055ed9972430 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Wed, 29 Apr 2026 10:01:23 +0200 Subject: [PATCH 3/6] fix: preserve anchors from no-merge branch tails Symptom: a nested IF whose continuing branch skips a local merge could lose its @anchor metadata when the parent IF connected that tail to its merge or next statement. In affected models this changed branch layout and could make Studio Pro report variables as out of scope after round-trip. Root cause: addIfStatement only consumed pending branch metadata when nextFlowCase was non-empty. No-merge continuations can carry only nextFlowAnchor, so those anchors were ignored. Fix: treat pending anchors as pending flow metadata even without a case value, and route their origin/destination sides through shared helper functions. Tests: added a synthetic nested no-merge IF regression that asserts the branch tail preserves a bottom-to-top anchor when it connects to the parent merge, and ran make build plus make test. --- .../cmd_microflows_builder_control.go | 80 +++++++++++-------- mdl/executor/cmd_microflows_builder_flows.go | 6 +- .../cmd_microflows_builder_no_merge_test.go | 61 ++++++++++++++ 3 files changed, 112 insertions(+), 35 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 51ed8d8c..c09036b4 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -135,17 +135,20 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { fb.flows = append(fb.flows, flow) } else { var flow *microflows.SequenceFlow - if pendingThenCase != "" { + originAnchor := prevThenAnchor + destAnchor := thisAnchor + if pendingThenCase != "" || pendingThenAnchor != nil { + originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, thisAnchor) flow = newHorizontalFlowWithCase(lastThenID, actID, pendingThenCase) - if pendingThenAnchor != nil { - prevThenAnchor = pendingThenAnchor + if pendingThenCase == "" { + flow = newHorizontalFlow(lastThenID, actID) } pendingThenCase = "" pendingThenAnchor = nil } else { flow = newHorizontalFlow(lastThenID, actID) } - applyUserAnchors(flow, prevThenAnchor, thisAnchor) + applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } prevThenAnchor = thisAnchor @@ -169,15 +172,18 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if !thenReturns && needMerge { if lastThenID != "" { var flow *microflows.SequenceFlow - if pendingThenCase != "" { + originAnchor := prevThenAnchor + destAnchor := (*ast.FlowAnchors)(nil) + if pendingThenCase != "" || pendingThenAnchor != nil { + originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, nil) flow = newHorizontalFlowWithCase(lastThenID, mergeID, pendingThenCase) - if pendingThenAnchor != nil { - prevThenAnchor = pendingThenAnchor + if pendingThenCase == "" { + flow = newHorizontalFlow(lastThenID, mergeID) } } else { flow = newHorizontalFlow(lastThenID, mergeID) } - applyUserAnchors(flow, prevThenAnchor, nil) + applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } else { // Empty THEN body - connect split directly to merge with true case. @@ -212,17 +218,20 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { fb.flows = append(fb.flows, flow) } else { var flow *microflows.SequenceFlow - if pendingElseCase != "" { + originAnchor := prevElseAnchor + destAnchor := thisAnchor + if pendingElseCase != "" || pendingElseAnchor != nil { + originAnchor, destAnchor = pendingFlowAnchors(prevElseAnchor, pendingElseAnchor, thisAnchor) flow = newHorizontalFlowWithCase(lastElseID, actID, pendingElseCase) - if pendingElseAnchor != nil { - prevElseAnchor = pendingElseAnchor + if pendingElseCase == "" { + flow = newHorizontalFlow(lastElseID, actID) } pendingElseCase = "" pendingElseAnchor = nil } else { flow = newHorizontalFlow(lastElseID, actID) } - applyUserAnchors(flow, prevElseAnchor, thisAnchor) + applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } prevElseAnchor = thisAnchor @@ -246,16 +255,18 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if !elseReturns && needMerge { if lastElseID != "" { flow := newUpwardFlow(lastElseID, mergeID) - if pendingElseCase != "" { - flow.CaseValue = microflows.EnumerationCase{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - Value: pendingElseCase, - } - if pendingElseAnchor != nil { - prevElseAnchor = pendingElseAnchor + originAnchor := prevElseAnchor + destAnchor := (*ast.FlowAnchors)(nil) + if pendingElseCase != "" || pendingElseAnchor != nil { + originAnchor, destAnchor = pendingFlowAnchors(prevElseAnchor, pendingElseAnchor, nil) + if pendingElseCase != "" { + flow.CaseValue = microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: pendingElseCase, + } } } - applyUserAnchors(flow, prevElseAnchor, nil) + applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } } @@ -327,17 +338,20 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { fb.flows = append(fb.flows, flow) } else { var flow *microflows.SequenceFlow - if pendingThenCase != "" { + originAnchor := prevThenAnchor + destAnchor := thisAnchor + if pendingThenCase != "" || pendingThenAnchor != nil { + originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, thisAnchor) flow = newHorizontalFlowWithCase(lastThenID, actID, pendingThenCase) - if pendingThenAnchor != nil { - prevThenAnchor = pendingThenAnchor + if pendingThenCase == "" { + flow = newHorizontalFlow(lastThenID, actID) } pendingThenCase = "" pendingThenAnchor = nil } else { flow = newHorizontalFlow(lastThenID, actID) } - applyUserAnchors(flow, prevThenAnchor, thisAnchor) + applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } prevThenAnchor = thisAnchor @@ -361,16 +375,18 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { if !thenReturns && needMerge { if lastThenID != "" { flow := newUpwardFlow(lastThenID, mergeID) - if pendingThenCase != "" { - flow.CaseValue = microflows.EnumerationCase{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - Value: pendingThenCase, - } - if pendingThenAnchor != nil { - prevThenAnchor = pendingThenAnchor + originAnchor := prevThenAnchor + destAnchor := (*ast.FlowAnchors)(nil) + if pendingThenCase != "" || pendingThenAnchor != nil { + originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, nil) + if pendingThenCase != "" { + flow.CaseValue = microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: pendingThenCase, + } } } - applyUserAnchors(flow, prevThenAnchor, nil) + applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) } else { // Empty THEN body - connect split directly to merge going down and back up. diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 9077fb6a..a93a5cf3 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -193,10 +193,10 @@ func applyUserAnchors(flow *microflows.SequenceFlow, origin *ast.FlowAnchors, de } func branchDestinationAnchor(branchAnchor, stmtAnchor *ast.FlowAnchors) *ast.FlowAnchors { - if stmtAnchor != nil && stmtAnchor.To != ast.AnchorSideUnset { - return stmtAnchor + if branchAnchor != nil && branchAnchor.To != ast.AnchorSideUnset { + return branchAnchor } - return branchAnchor + return stmtAnchor } func pendingFlowAnchors(previousAnchor, pendingAnchor, stmtAnchor *ast.FlowAnchors) (*ast.FlowAnchors, *ast.FlowAnchors) { diff --git a/mdl/executor/cmd_microflows_builder_no_merge_test.go b/mdl/executor/cmd_microflows_builder_no_merge_test.go index 583e4ab9..9dba81d7 100644 --- a/mdl/executor/cmd_microflows_builder_no_merge_test.go +++ b/mdl/executor/cmd_microflows_builder_no_merge_test.go @@ -46,6 +46,58 @@ func TestBuildFlowGraph_NoMergeIfElseContinuesFromBranchTail(t *testing.T) { } } +func TestBuildFlowGraph_NestedNoMergeTailCarriesAnchorToParentMerge(t *testing.T) { + anchoredTail := &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "nested else tail"}, + Annotations: &ast.ActivityAnnotations{ + Anchor: &ast.FlowAnchors{From: ast.AnchorSideBottom, To: ast.AnchorSideTop}, + }, + } + body := []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "Outer"}, + ThenBody: []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "Inner"}, + ThenBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}}, + }, + ElseBody: []ast.MicroflowStatement{anchoredTail}, + }, + }, + ElseBody: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "outer else"}}, + }, + }, + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "after outer"}}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"Outer": "Boolean", "Inner": "Boolean"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeBoolean}}) + + tailID := findLogActivityIDByMessage(t, oc, "nested else tail") + for _, flow := range oc.Flows { + if flow.OriginID != tailID { + continue + } + if _, ok := objectByID(oc, flow.DestinationID).(*microflows.ExclusiveMerge); !ok { + continue + } + if flow.OriginConnectionIndex != AnchorBottom || flow.DestinationConnectionIndex != AnchorTop { + t.Fatalf("nested no-merge tail anchor = from %d to %d, want bottom/top", flow.OriginConnectionIndex, flow.DestinationConnectionIndex) + } + return + } + t.Fatal("expected nested no-merge tail to connect to the parent merge") +} + func findLogActivityIDByMessage(t *testing.T, oc *microflows.MicroflowObjectCollection, message string) model.ID { t.Helper() for _, obj := range oc.Objects { @@ -65,6 +117,15 @@ func findLogActivityIDByMessage(t *testing.T, oc *microflows.MicroflowObjectColl return "" } +func objectByID(oc *microflows.MicroflowObjectCollection, id model.ID) microflows.MicroflowObject { + for _, obj := range oc.Objects { + if obj.GetID() == id { + return obj + } + } + return nil +} + func hasSequenceFlow(flows []*microflows.SequenceFlow, originID, destinationID model.ID) bool { for _, flow := range flows { if flow.OriginID == originID && flow.DestinationID == destinationID { From c49894a526a1826e07b8f4e7d1364ee0def86c03 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 18:22:42 +0200 Subject: [PATCH 4/6] fix: preserve multi-statement guard continuations Symptom: describe could print the false-path continuation of an if-without-else inside an else block when the true branch terminated through multiple statements. Root cause: guard detection only recognized a true branch that jumped directly to an EndEvent. Multi-statement terminal true branches fell back to normal IF/ELSE rendering. Fix: detect terminal true branches recursively and use layout to identify the false flow as the horizontal continuation rather than an explicit else branch. Tests: add a traversal regression with a multi-statement terminal true branch and a false-path tail that must remain after end if. --- mdl/executor/cmd_microflows_show_helpers.go | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index d0505d84..4add3180 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -649,7 +649,7 @@ func traverseFlow( } trueTerminates := branchFlowTerminatesBeforeMerge(trueFlow, mergeID, activityMap, flowsByOrigin, splitMergeMap) - isGuard := trueTerminates && !branchFlowStartsAtTerminal(falseFlow, activityMap) + isGuard := trueTerminates && flowLooksLikeGuardContinuation(falseFlow, obj, activityMap) if isGuard { traverseFlowUntilMerge(ctx, trueFlow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) @@ -799,7 +799,7 @@ func traverseFlowUntilMerge( } trueTerminates := branchFlowTerminatesBeforeMerge(trueFlow, nestedMergeID, activityMap, flowsByOrigin, splitMergeMap) - isGuard := trueTerminates && !branchFlowStartsAtTerminal(falseFlow, activityMap) + isGuard := trueTerminates && flowLooksLikeGuardContinuation(falseFlow, obj, activityMap) if isGuard { traverseFlowUntilMerge(ctx, trueFlow.DestinationID, nestedMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) @@ -1205,6 +1205,25 @@ func findBranchFlows(flows []*microflows.SequenceFlow) (trueFlow, falseFlow *mic return trueFlow, falseFlow } +func flowLooksLikeGuardContinuation( + flow *microflows.SequenceFlow, + split microflows.MicroflowObject, + activityMap map[model.ID]microflows.MicroflowObject, +) bool { + if flow == nil || split == nil { + return false + } + dest := activityMap[flow.DestinationID] + if dest == nil { + return false + } + switch dest.(type) { + case *microflows.EndEvent, *microflows.ErrorEvent: + return false + } + return dest.GetPosition().Y == split.GetPosition().Y +} + // findErrorHandlerFlow returns the error handler flow from an activity's outgoing flows. func findErrorHandlerFlow(flows []*microflows.SequenceFlow) *microflows.SequenceFlow { for _, flow := range flows { From bbc87eb184e87cbbf50cd38fc099dc4d88fbffd4 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 18:50:38 +0200 Subject: [PATCH 5/6] test: avoid no-merge helper collision The no-merge branch test helper used the generic name objectByID. When combined with the enum-split tests, the executor package had two helpers with the same name and failed to compile. Rename the helper to noMergeObjectByID so both PRs can be merged together without changing test behavior. Tests: - go test ./mdl/executor -run 'TestBuildFlowGraph_NoMerge|TestBuildFlowGraph_NestedNoMerge' -count=1 - make build - make test --- mdl/executor/cmd_microflows_builder_no_merge_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_no_merge_test.go b/mdl/executor/cmd_microflows_builder_no_merge_test.go index 9dba81d7..e397d5c8 100644 --- a/mdl/executor/cmd_microflows_builder_no_merge_test.go +++ b/mdl/executor/cmd_microflows_builder_no_merge_test.go @@ -87,7 +87,7 @@ func TestBuildFlowGraph_NestedNoMergeTailCarriesAnchorToParentMerge(t *testing.T if flow.OriginID != tailID { continue } - if _, ok := objectByID(oc, flow.DestinationID).(*microflows.ExclusiveMerge); !ok { + if _, ok := noMergeObjectByID(oc, flow.DestinationID).(*microflows.ExclusiveMerge); !ok { continue } if flow.OriginConnectionIndex != AnchorBottom || flow.DestinationConnectionIndex != AnchorTop { @@ -117,7 +117,7 @@ func findLogActivityIDByMessage(t *testing.T, oc *microflows.MicroflowObjectColl return "" } -func objectByID(oc *microflows.MicroflowObjectCollection, id model.ID) microflows.MicroflowObject { +func noMergeObjectByID(oc *microflows.MicroflowObjectCollection, id model.ID) microflows.MicroflowObject { for _, obj := range oc.Objects { if obj.GetID() == id { return obj From b3b5fac2f1f62de328c6676f40a20d1a91a7643e Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 19:52:02 +0200 Subject: [PATCH 6/6] fix: document no-merge anchor routing invariants Symptom: review noted that no-merge branch continuation routing relied on anchor precedence and guard-layout heuristics that were not visible in code or tests. Root cause: the implementation preserved the right flow metadata but left the split-to-branch destination precedence and guard continuation layout contract implicit. Fix: add comments for branch destination precedence, guard continuation Y matching, and the defensive no-merge fallback; add a regression test pinning that branch-level `to` anchors win on the split-to-branch flow. Tests: run the focused no-merge/anchor tests, then make build and make test. --- mdl/executor/cmd_microflows_builder_anchor_test.go | 13 +++++++++++++ mdl/executor/cmd_microflows_builder_control.go | 4 ++++ mdl/executor/cmd_microflows_builder_flows.go | 4 ++++ mdl/executor/cmd_microflows_show_helpers.go | 4 ++++ 4 files changed, 25 insertions(+) diff --git a/mdl/executor/cmd_microflows_builder_anchor_test.go b/mdl/executor/cmd_microflows_builder_anchor_test.go index 59ee5894..09b84276 100644 --- a/mdl/executor/cmd_microflows_builder_anchor_test.go +++ b/mdl/executor/cmd_microflows_builder_anchor_test.go @@ -153,3 +153,16 @@ func TestBuilder_IfBranchAnchorOverrides(t *testing.T) { falseF.OriginConnectionIndex, falseF.DestinationConnectionIndex, AnchorBottom, AnchorTop) } } + +func TestBranchDestinationAnchorPrefersBranchToSide(t *testing.T) { + branchAnchor := &ast.FlowAnchors{From: ast.AnchorSideRight, To: ast.AnchorSideTop} + stmtAnchor := &ast.FlowAnchors{From: ast.AnchorSideBottom, To: ast.AnchorSideLeft} + + got := branchDestinationAnchor(branchAnchor, stmtAnchor) + if got != branchAnchor { + t.Fatal("expected branch-level destination anchor to win for split-to-branch flow") + } + if got.To != ast.AnchorSideTop { + t.Fatalf("destination side = %v, want top", got.To) + } +} diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index c09036b4..c687c1c2 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -434,6 +434,10 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { fb.nextFlowCase = noMergeExitCase fb.nextFlowAnchor = noMergeExitAnchor } else { + // Defensive fallback: the no-merge path above always records the + // continuing branch tail in noMergeExitID. If future branch handling + // changes violate that invariant, continue from the split rather than + // leaving the parent disconnected. fb.nextConnectionPoint = splitID } } diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index a93a5cf3..7cb0e83d 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -193,6 +193,10 @@ func applyUserAnchors(flow *microflows.SequenceFlow, origin *ast.FlowAnchors, de } func branchDestinationAnchor(branchAnchor, stmtAnchor *ast.FlowAnchors) *ast.FlowAnchors { + // The split branch annotation owns the incoming edge to the first branch + // activity. If it specifies `to`, it must win over the first statement's + // own anchor; the statement anchor applies to that activity's outgoing + // edge, not to the split->statement flow. if branchAnchor != nil && branchAnchor.To != ast.AnchorSideUnset { return branchAnchor } diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 4add3180..fdc956d4 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1221,6 +1221,10 @@ func flowLooksLikeGuardContinuation( case *microflows.EndEvent, *microflows.ErrorEvent: return false } + // Builder-generated guard continuations sit on the split's horizontal + // centerline. This intentionally relies on mxcli's layout contract so a + // real branch that returns to a merge below the split is not collapsed into + // a guard-style continuation during describe. return dest.GetPosition().Y == split.GetPosition().Y }