Skip to content

Commit 48881e7

Browse files
hjothaclaude
authored andcommitted
fix: preserve decision/loop captions across nested control flow
When an IF, LOOP, or WHILE statement carried an @caption or @annotation and contained other annotated statements in its body, the inner statement's annotations would overwrite fb.pendingAnnotations (shared builder state) before the outer loop in buildFlowGraph attached them to the right object. The outer split/loop then ended up with the wrong caption — or with the inner statement's caption applied to it. The fix snapshots & clears pendingAnnotations at the top of each compound statement handler, re-applies them to the split/loop/while activity right after it's created, and (for IF) moves applyAnnotations above the branch recursion so the split is decorated before the bodies are walked. Also extends applyAnnotations to recognise ExclusiveSplit and InheritanceSplit — on main these were only handled for ActionActivity, so @caption on a split was silently dropped even without nesting. Verified end-to-end against Apps.GetOrCreateMendixVersionFromString: describe -> exec -> describe now produces byte-identical @caption, @annotation, and @position output. Regression tests cover: - nested IFs with explicit @caption on each level - single IF @caption baseline - @annotation attachment targeting the correct split when nested Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 96a5d9e commit 48881e7

3 files changed

Lines changed: 263 additions & 2 deletions

File tree

mdl/executor/cmd_microflows_builder_annotations.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
116116
continue
117117
}
118118

119-
// @caption, @color, and @excluded — only applicable to ActionActivity
120-
if activity, ok := obj.(*microflows.ActionActivity); ok {
119+
switch activity := obj.(type) {
120+
case *microflows.ActionActivity:
121121
if ann.Caption != "" {
122122
activity.Caption = ann.Caption
123123
activity.AutoGenerateCaption = false
@@ -128,6 +128,16 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
128128
if ann.Excluded {
129129
activity.Disabled = true
130130
}
131+
case *microflows.ExclusiveSplit:
132+
// Splits carry a human-readable Caption (e.g. "Right format?")
133+
// independent of the expression/rule being evaluated.
134+
if ann.Caption != "" {
135+
activity.Caption = ann.Caption
136+
}
137+
case *microflows.InheritanceSplit:
138+
if ann.Caption != "" {
139+
activity.Caption = ann.Caption
140+
}
131141
}
132142

133143
break
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/ast"
9+
"github.com/mendixlabs/mxcli/sdk/microflows"
10+
)
11+
12+
// TestNestedIfPreservesCaptions is a regression test for the bug where
13+
// the outer IF's @caption would be overwritten by the inner IF's @caption
14+
// because pendingAnnotations is shared mutable state across recursive
15+
// addStatement calls.
16+
//
17+
// Before the fix:
18+
// - outer ExclusiveSplit received caption "Right format?" (from inner IF)
19+
// - inner ExclusiveSplit kept its condition expression as caption
20+
// - inner IF's @annotation got attached to the outer split
21+
//
22+
// After the fix:
23+
// - addIfStatement consumes its own pendingAnnotations right after
24+
// creating its split, so outer and inner captions stay bound to the
25+
// correct splits.
26+
func TestNestedIfPreservesCaptions(t *testing.T) {
27+
// Build an AST equivalent to:
28+
// if $S != empty @caption 'String not empty?'
29+
// if isMatch($S, 'x') @caption 'Right format?'
30+
// return true
31+
// else
32+
// return false
33+
// else
34+
// return false
35+
innerIf := &ast.IfStmt{
36+
Condition: &ast.FunctionCallExpr{
37+
Name: "isMatch",
38+
Arguments: []ast.Expression{&ast.VariableExpr{Name: "S"}, &ast.LiteralExpr{Value: "x", Kind: ast.LiteralString}},
39+
},
40+
ThenBody: []ast.MicroflowStatement{
41+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}},
42+
},
43+
ElseBody: []ast.MicroflowStatement{
44+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: false, Kind: ast.LiteralBoolean}},
45+
},
46+
Annotations: &ast.ActivityAnnotations{Caption: "Right format?"},
47+
}
48+
outerIf := &ast.IfStmt{
49+
Condition: &ast.BinaryExpr{
50+
Left: &ast.VariableExpr{Name: "S"},
51+
Operator: "!=",
52+
Right: &ast.LiteralExpr{Value: nil, Kind: ast.LiteralNull},
53+
},
54+
ThenBody: []ast.MicroflowStatement{innerIf},
55+
ElseBody: []ast.MicroflowStatement{
56+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: false, Kind: ast.LiteralBoolean}},
57+
},
58+
Annotations: &ast.ActivityAnnotations{Caption: "String not empty?"},
59+
}
60+
61+
fb := &flowBuilder{
62+
posX: 100,
63+
posY: 100,
64+
spacing: HorizontalSpacing,
65+
varTypes: map[string]string{"S": "String"},
66+
declaredVars: map[string]string{"S": "String"},
67+
}
68+
fb.buildFlowGraph([]ast.MicroflowStatement{outerIf}, nil)
69+
70+
// Collect ExclusiveSplits with their captions. The outer split is created
71+
// first, so objects[1] is the outer split (objects[0] is the StartEvent).
72+
var splits []*microflows.ExclusiveSplit
73+
for _, obj := range fb.objects {
74+
if sp, ok := obj.(*microflows.ExclusiveSplit); ok {
75+
splits = append(splits, sp)
76+
}
77+
}
78+
79+
if len(splits) != 2 {
80+
t.Fatalf("expected 2 ExclusiveSplits, got %d", len(splits))
81+
}
82+
83+
// Splits are appended in creation order: outer first (from outerIf),
84+
// then inner (when recursion into ThenBody creates the nested IF's split).
85+
outerSplit, innerSplit := splits[0], splits[1]
86+
87+
if outerSplit.Caption != "String not empty?" {
88+
t.Errorf("outer split caption: got %q, want %q", outerSplit.Caption, "String not empty?")
89+
}
90+
if innerSplit.Caption != "Right format?" {
91+
t.Errorf("inner split caption: got %q, want %q", innerSplit.Caption, "Right format?")
92+
}
93+
}
94+
95+
// TestIfCaptionWithoutNesting confirms a single IF with @caption still gets
96+
// the right caption after the fix (baseline sanity check).
97+
func TestIfCaptionWithoutNesting(t *testing.T) {
98+
ifStmt := &ast.IfStmt{
99+
Condition: &ast.BinaryExpr{
100+
Left: &ast.VariableExpr{Name: "S"},
101+
Operator: "!=",
102+
Right: &ast.LiteralExpr{Value: nil, Kind: ast.LiteralNull},
103+
},
104+
ThenBody: []ast.MicroflowStatement{
105+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}},
106+
},
107+
ElseBody: []ast.MicroflowStatement{
108+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: false, Kind: ast.LiteralBoolean}},
109+
},
110+
Annotations: &ast.ActivityAnnotations{Caption: "String not empty?"},
111+
}
112+
113+
fb := &flowBuilder{
114+
posX: 100,
115+
posY: 100,
116+
spacing: HorizontalSpacing,
117+
varTypes: map[string]string{"S": "String"},
118+
declaredVars: map[string]string{"S": "String"},
119+
}
120+
fb.buildFlowGraph([]ast.MicroflowStatement{ifStmt}, nil)
121+
122+
for _, obj := range fb.objects {
123+
if sp, ok := obj.(*microflows.ExclusiveSplit); ok {
124+
if sp.Caption != "String not empty?" {
125+
t.Errorf("split caption: got %q, want %q", sp.Caption, "String not empty?")
126+
}
127+
return
128+
}
129+
}
130+
t.Fatal("no ExclusiveSplit found")
131+
}
132+
133+
// TestIfAnnotationStaysWithCorrectSplit confirms @annotation on a nested IF
134+
// attaches to that IF's split, not to the outer one.
135+
func TestIfAnnotationStaysWithCorrectSplit(t *testing.T) {
136+
innerIf := &ast.IfStmt{
137+
Condition: &ast.FunctionCallExpr{
138+
Name: "isMatch",
139+
Arguments: []ast.Expression{&ast.VariableExpr{Name: "S"}, &ast.LiteralExpr{Value: "x", Kind: ast.LiteralString}},
140+
},
141+
ThenBody: []ast.MicroflowStatement{
142+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}},
143+
},
144+
ElseBody: []ast.MicroflowStatement{
145+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: false, Kind: ast.LiteralBoolean}},
146+
},
147+
Annotations: &ast.ActivityAnnotations{
148+
Caption: "Right format?",
149+
AnnotationText: "Inner IF note",
150+
},
151+
}
152+
outerIf := &ast.IfStmt{
153+
Condition: &ast.BinaryExpr{
154+
Left: &ast.VariableExpr{Name: "S"},
155+
Operator: "!=",
156+
Right: &ast.LiteralExpr{Value: nil, Kind: ast.LiteralNull},
157+
},
158+
ThenBody: []ast.MicroflowStatement{innerIf},
159+
ElseBody: []ast.MicroflowStatement{
160+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: false, Kind: ast.LiteralBoolean}},
161+
},
162+
Annotations: &ast.ActivityAnnotations{
163+
Caption: "String not empty?",
164+
AnnotationText: "Outer IF note",
165+
},
166+
}
167+
168+
fb := &flowBuilder{
169+
posX: 100,
170+
posY: 100,
171+
spacing: HorizontalSpacing,
172+
varTypes: map[string]string{"S": "String"},
173+
declaredVars: map[string]string{"S": "String"},
174+
}
175+
fb.buildFlowGraph([]ast.MicroflowStatement{outerIf}, nil)
176+
177+
var splits []*microflows.ExclusiveSplit
178+
var annotations []*microflows.Annotation
179+
for _, obj := range fb.objects {
180+
switch o := obj.(type) {
181+
case *microflows.ExclusiveSplit:
182+
splits = append(splits, o)
183+
case *microflows.Annotation:
184+
annotations = append(annotations, o)
185+
}
186+
}
187+
188+
if len(splits) != 2 {
189+
t.Fatalf("expected 2 splits, got %d", len(splits))
190+
}
191+
if len(annotations) != 2 {
192+
t.Fatalf("expected 2 annotations, got %d", len(annotations))
193+
}
194+
195+
outerSplit, innerSplit := splits[0], splits[1]
196+
197+
// AnnotationFlow links Annotation -> activity. Verify each flow points
198+
// from the annotation with the expected text to the expected split.
199+
var outerNoteDestID, innerNoteDestID string
200+
for _, af := range fb.annotationFlows {
201+
// Find the Annotation referenced by OriginID
202+
for _, ann := range annotations {
203+
if ann.ID != af.OriginID {
204+
continue
205+
}
206+
switch ann.Caption {
207+
case "Outer IF note":
208+
outerNoteDestID = string(af.DestinationID)
209+
case "Inner IF note":
210+
innerNoteDestID = string(af.DestinationID)
211+
}
212+
}
213+
}
214+
215+
if outerNoteDestID != string(outerSplit.ID) {
216+
t.Errorf("outer note destination: got %q, want %q (outer split)", outerNoteDestID, outerSplit.ID)
217+
}
218+
if innerNoteDestID != string(innerSplit.ID) {
219+
t.Errorf("inner note destination: got %q, want %q (inner split)", innerNoteDestID, innerSplit.ID)
220+
}
221+
}

mdl/executor/cmd_microflows_builder_control.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
6262
fb.objects = append(fb.objects, split)
6363
splitID := split.ID
6464

65+
// Apply this IF's own annotations (e.g. @caption, @annotation) to the split
66+
// BEFORE recursing into branch bodies. Otherwise a nested statement's annotations
67+
// would overwrite fb.pendingAnnotations (shared state) and the outer loop in
68+
// buildFlowGraph would then attach the wrong caption/annotation to this split.
69+
if fb.pendingAnnotations != nil {
70+
fb.applyAnnotations(splitID, fb.pendingAnnotations)
71+
fb.pendingAnnotations = nil
72+
}
73+
6574
// Calculate merge position (after the longest branch)
6675
mergeX := splitX + SplitWidth + HorizontalSpacing/2 + branchWidth + HorizontalSpacing/2
6776

@@ -251,6 +260,12 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
251260
// addLoopStatement creates a LOOP statement using LoopedActivity.
252261
// Layout: Auto-sizes the loop box to fit content with padding
253262
func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
263+
// Snapshot & clear this loop's own annotations so the body's recursive
264+
// addStatement calls can't consume them. We re-apply to the loop activity
265+
// after it's created below.
266+
savedLoopAnnotations := fb.pendingAnnotations
267+
fb.pendingAnnotations = nil
268+
254269
// First, measure the loop body to determine size
255270
bodyBounds := fb.measurer.measureStatements(s.Body)
256271

@@ -335,6 +350,11 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
335350
// This is how Mendix stores them - all flows at the microflow level
336351
fb.flows = append(fb.flows, loopBuilder.flows...)
337352

353+
// Re-apply this loop's own annotations now that its activity exists.
354+
if savedLoopAnnotations != nil {
355+
fb.applyAnnotations(loop.ID, savedLoopAnnotations)
356+
}
357+
338358
fb.posX += loopWidth + HorizontalSpacing
339359

340360
return loop.ID
@@ -343,6 +363,11 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
343363
// addWhileStatement creates a WHILE loop using LoopedActivity with WhileLoopCondition.
344364
// Layout matches addLoopStatement but without iterator icon space.
345365
func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
366+
// Snapshot & clear this WHILE's own annotations so the body's recursive
367+
// addStatement calls can't consume them (see addLoopStatement).
368+
savedWhileAnnotations := fb.pendingAnnotations
369+
fb.pendingAnnotations = nil
370+
346371
bodyBounds := fb.measurer.measureStatements(s.Body)
347372

348373
loopWidth := max(bodyBounds.Width+2*LoopPadding, MinLoopWidth)
@@ -402,6 +427,11 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
402427

403428
fb.objects = append(fb.objects, loop)
404429
fb.flows = append(fb.flows, loopBuilder.flows...)
430+
431+
if savedWhileAnnotations != nil {
432+
fb.applyAnnotations(loop.ID, savedWhileAnnotations)
433+
}
434+
405435
fb.posX += loopWidth + HorizontalSpacing
406436

407437
return loop.ID

0 commit comments

Comments
 (0)