Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions mdl-examples/bug-tests/351-no-merge-branch-continuation.mdl
Original file line number Diff line number Diff line change
@@ -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;
/
13 changes: 13 additions & 0 deletions mdl/executor/cmd_microflows_builder_anchor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
173 changes: 148 additions & 25 deletions mdl/executor/cmd_microflows_builder_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -129,15 +134,32 @@ 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)
applyUserAnchors(flow, prevThenAnchor, thisAnchor)
var flow *microflows.SequenceFlow
originAnchor := prevThenAnchor
destAnchor := thisAnchor
if pendingThenCase != "" || pendingThenAnchor != nil {
originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, thisAnchor)
flow = newHorizontalFlowWithCase(lastThenID, actID, pendingThenCase)
if pendingThenCase == "" {
flow = newHorizontalFlow(lastThenID, actID)
}
pendingThenCase = ""
pendingThenAnchor = nil
} else {
flow = newHorizontalFlow(lastThenID, actID)
}
applyUserAnchors(flow, originAnchor, destAnchor)
fb.flows = append(fb.flows, flow)
}
prevThenAnchor = thisAnchor
// For nested compound statements, use their exit point
if fb.nextConnectionPoint != "" {
lastThenID = fb.nextConnectionPoint
fb.nextConnectionPoint = ""
pendingThenCase = fb.nextFlowCase
fb.nextFlowCase = ""
pendingThenAnchor = fb.nextFlowAnchor
fb.nextFlowAnchor = nil
} else {
lastThenID = actID
}
Expand All @@ -149,8 +171,19 @@ 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)
applyUserAnchors(flow, prevThenAnchor, nil)
var flow *microflows.SequenceFlow
originAnchor := prevThenAnchor
destAnchor := (*ast.FlowAnchors)(nil)
if pendingThenCase != "" || pendingThenAnchor != nil {
originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, nil)
flow = newHorizontalFlowWithCase(lastThenID, mergeID, pendingThenCase)
if pendingThenCase == "" {
flow = newHorizontalFlow(lastThenID, mergeID)
}
} else {
flow = newHorizontalFlow(lastThenID, mergeID)
}
applyUserAnchors(flow, originAnchor, destAnchor)
fb.flows = append(fb.flows, flow)
} else {
// Empty THEN body - connect split directly to merge with true case.
Expand All @@ -170,6 +203,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)
Expand All @@ -182,15 +217,32 @@ 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)
applyUserAnchors(flow, prevElseAnchor, thisAnchor)
var flow *microflows.SequenceFlow
originAnchor := prevElseAnchor
destAnchor := thisAnchor
if pendingElseCase != "" || pendingElseAnchor != nil {
originAnchor, destAnchor = pendingFlowAnchors(prevElseAnchor, pendingElseAnchor, thisAnchor)
flow = newHorizontalFlowWithCase(lastElseID, actID, pendingElseCase)
if pendingElseCase == "" {
flow = newHorizontalFlow(lastElseID, actID)
}
pendingElseCase = ""
pendingElseAnchor = nil
} else {
flow = newHorizontalFlow(lastElseID, actID)
}
applyUserAnchors(flow, originAnchor, destAnchor)
fb.flows = append(fb.flows, flow)
}
prevElseAnchor = thisAnchor
// For nested compound statements, use their exit point
if fb.nextConnectionPoint != "" {
lastElseID = fb.nextConnectionPoint
fb.nextConnectionPoint = ""
pendingElseCase = fb.nextFlowCase
fb.nextFlowCase = ""
pendingElseAnchor = fb.nextFlowAnchor
fb.nextFlowAnchor = nil
} else {
lastElseID = actID
}
Expand All @@ -203,10 +255,52 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
if !elseReturns && needMerge {
if lastElseID != "" {
flow := newUpwardFlow(lastElseID, mergeID)
applyUserAnchors(flow, prevElseAnchor, nil)
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, originAnchor, destAnchor)
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
Expand All @@ -230,6 +324,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)
Expand All @@ -241,15 +337,32 @@ 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)
applyUserAnchors(flow, prevThenAnchor, thisAnchor)
var flow *microflows.SequenceFlow
originAnchor := prevThenAnchor
destAnchor := thisAnchor
if pendingThenCase != "" || pendingThenAnchor != nil {
originAnchor, destAnchor = pendingFlowAnchors(prevThenAnchor, pendingThenAnchor, thisAnchor)
flow = newHorizontalFlowWithCase(lastThenID, actID, pendingThenCase)
if pendingThenCase == "" {
flow = newHorizontalFlow(lastThenID, actID)
}
pendingThenCase = ""
pendingThenAnchor = nil
} else {
flow = newHorizontalFlow(lastThenID, actID)
}
applyUserAnchors(flow, originAnchor, destAnchor)
fb.flows = append(fb.flows, flow)
}
prevThenAnchor = thisAnchor
// For nested compound statements, use their exit point
if fb.nextConnectionPoint != "" {
lastThenID = fb.nextConnectionPoint
fb.nextConnectionPoint = ""
pendingThenCase = fb.nextFlowCase
fb.nextFlowCase = ""
pendingThenAnchor = fb.nextFlowAnchor
fb.nextFlowAnchor = nil
} else {
lastThenID = actID
}
Expand All @@ -262,7 +375,18 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
if !thenReturns && needMerge {
if lastThenID != "" {
flow := newUpwardFlow(lastThenID, mergeID)
applyUserAnchors(flow, prevThenAnchor, nil)
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, originAnchor, destAnchor)
fb.flows = append(fb.flows, flow)
} else {
// Empty THEN body - connect split directly to merge going down and back up.
Expand All @@ -273,6 +397,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
Expand Down Expand Up @@ -300,22 +429,16 @@ 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
// 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
}
}

Expand Down
10 changes: 7 additions & 3 deletions mdl/executor/cmd_microflows_builder_flows.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,14 @@ 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
// 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
}
return branchAnchor
return stmtAnchor
}

func pendingFlowAnchors(previousAnchor, pendingAnchor, stmtAnchor *ast.FlowAnchors) (*ast.FlowAnchors, *ast.FlowAnchors) {
Expand Down
Loading
Loading