From 15605e0d57ed80d99cc8ff0e91ac3375bb8a1469 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sun, 26 Apr 2026 17:58:09 +0200 Subject: [PATCH 1/2] fix: preserve annotations attached inside loop bodies Symptom: annotations attached to statements inside loop or while bodies could disappear after describe/exec because nested annotation flows were not visible from the traversal context. Root cause: loop body builders copied only sequence flows back to the parent builder, describe collected annotation captions only from top-level objects, and loop-local annotation flows were not merged into the annotation map used while traversing loop bodies. Fix: promote nested loop annotation flows to the parent graph, collect annotation captions recursively from nested loop object collections, and merge loop-local annotations into loop body traversal. Tests: add regression coverage for an annotated nested decision built inside a loop body and for annotation flows stored directly in a loop object collection. --- ...cmd_microflows_builder_annotations_test.go | 48 ++++++++++++++++ .../cmd_microflows_builder_control.go | 2 + mdl/executor/cmd_microflows_show_helpers.go | 43 ++++++++++++--- mdl/executor/cmd_microflows_traverse_test.go | 55 +++++++++++++++++++ 4 files changed, 141 insertions(+), 7 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_annotations_test.go b/mdl/executor/cmd_microflows_builder_annotations_test.go index bd471d8d..4a562659 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" ) @@ -220,6 +221,53 @@ func TestIfAnnotationStaysWithCorrectSplit(t *testing.T) { } } +func TestLoopBodyIfAnnotationPromotedToParentFlows(t *testing.T) { + nestedIf := &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "IsActive"}, + ThenBody: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "active"}}, + }, + Annotations: &ast.ActivityAnnotations{ + AnnotationText: "Nested decision note", + }, + } + loop := &ast.LoopStmt{ + LoopVariable: "Item", + ListVariable: "Items", + Body: []ast.MicroflowStatement{nestedIf}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{"Items": "List of Synthetic.Item", "IsActive": "Boolean"}, + declaredVars: map[string]string{"Items": "List of Synthetic.Item", "IsActive": "Boolean"}, + } + oc := fb.buildFlowGraph([]ast.MicroflowStatement{loop}, nil) + + var splitID model.ID + for _, obj := range oc.Objects { + loopObj, ok := obj.(*microflows.LoopedActivity) + if !ok || loopObj.ObjectCollection == nil { + continue + } + for _, nested := range loopObj.ObjectCollection.Objects { + if split, ok := nested.(*microflows.ExclusiveSplit); ok { + splitID = split.ID + } + } + } + if splitID == "" { + t.Fatal("expected nested ExclusiveSplit inside loop body") + } + + annotations := buildAnnotationsByTarget(oc) + if got := annotations[splitID]; len(got) != 1 || got[0] != "Nested decision note" { + t.Fatalf("annotations for nested split = %#v, want Nested decision note", got) + } +} + // TestLoopCaptionPreserved covers the loop caption case — previously untested // per PR review. The fix for the outer-IF caption contamination bug also applied // the same snapshot/restore pattern to addLoopStatement and addWhileStatement. diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 4841f912..3fa3ee66 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -428,6 +428,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { // Add the internal flows to the parent's flows (top-level), not inside loop // This is how Mendix stores them - all flows at the microflow level fb.flows = append(fb.flows, loopBuilder.flows...) + fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...) // Re-apply this loop's own annotations now that its activity exists. if savedLoopAnnotations != nil { @@ -517,6 +518,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { fb.objects = append(fb.objects, loop) fb.flows = append(fb.flows, loopBuilder.flows...) + fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...) if savedWhileAnnotations != nil { fb.applyAnnotations(loop.ID, savedWhileAnnotations) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 6c646abb..b901dd69 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -20,13 +20,8 @@ func buildAnnotationsByTarget(oc *microflows.MicroflowObjectCollection) map[mode return result } - // Build a map of annotation IDs to their captions annotCaptions := make(map[model.ID]string) - for _, obj := range oc.Objects { - if annot, ok := obj.(*microflows.Annotation); ok { - annotCaptions[annot.ID] = annot.Caption - } - } + collectAnnotationCaptions(oc, annotCaptions) // Map each annotation flow's destination (the activity) to the annotation's caption for _, af := range oc.AnnotationFlows { @@ -38,6 +33,38 @@ func buildAnnotationsByTarget(oc *microflows.MicroflowObjectCollection) map[mode return result } +func collectAnnotationCaptions(oc *microflows.MicroflowObjectCollection, captions map[model.ID]string) { + if oc == nil { + return + } + for _, obj := range oc.Objects { + if annot, ok := obj.(*microflows.Annotation); ok { + captions[annot.ID] = annot.Caption + continue + } + if loop, ok := obj.(*microflows.LoopedActivity); ok { + collectAnnotationCaptions(loop.ObjectCollection, captions) + } + } +} + +func mergeAnnotationsByTarget(base, overlay map[model.ID][]string) map[model.ID][]string { + if len(base) == 0 { + return overlay + } + if len(overlay) == 0 { + return base + } + merged := make(map[model.ID][]string, len(base)+len(overlay)) + for id, captions := range base { + merged[id] = captions + } + for id, captions := range overlay { + merged[id] = append(merged[id], captions...) + } + return merged +} + // collectFreeAnnotations returns captions for annotations not referenced by any AnnotationFlow. func collectFreeAnnotations(oc *microflows.MicroflowObjectCollection) []string { if oc == nil { @@ -826,6 +853,8 @@ func emitLoopBody( return } + loopAnnotationsByTarget := mergeAnnotationsByTarget(annotationsByTarget, buildAnnotationsByTarget(loop.ObjectCollection)) + // Build a map of objects in the loop body loopActivityMap := make(map[model.ID]microflows.MicroflowObject) for _, loopObj := range loop.ObjectCollection.Objects { @@ -888,7 +917,7 @@ func emitLoopBody( // Traverse the loop body if firstID != "" { loopVisited := make(map[model.ID]bool) - traverseLoopBody(ctx, firstID, loopActivityMap, loopFlowsByOrigin, loopFlowsByDest, loopVisited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) + traverseLoopBody(ctx, firstID, loopActivityMap, loopFlowsByOrigin, loopFlowsByDest, loopVisited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, loopAnnotationsByTarget) } } diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index f924ddd1..5743dc4c 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -3,6 +3,7 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/model" @@ -124,6 +125,60 @@ func TestTraverseFlow_IfElse(t *testing.T) { } } +func TestTraverseFlow_LoopBodyUsesNestedAnnotationFlows(t *testing.T) { + e := newTestExecutor() + + split := µflows.ExclusiveSplit{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: mkID("split")}, + Position: model.Point{X: 100, Y: 100}, + }, + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Item/IsActive"}, + } + note := µflows.Annotation{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: mkID("note")}, + Position: model.Point{X: 1000, Y: 100}, + }, + Caption: "nested split note", + } + loopObjects := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{split, note}, + AnnotationFlows: []*microflows.AnnotationFlow{ + { + BaseElement: model.BaseElement{ID: mkID("note-flow")}, + OriginID: mkID("note"), + DestinationID: mkID("split"), + }, + }, + } + annotationsByTarget := mergeAnnotationsByTarget( + buildAnnotationsByTarget(µflows.MicroflowObjectCollection{}), + buildAnnotationsByTarget(loopObjects), + ) + + var lines []string + e.traverseFlow( + mkID("split"), + map[model.ID]microflows.MicroflowObject{mkID("split"): split}, + nil, + nil, + make(map[model.ID]bool), + nil, + nil, + &lines, + 0, + nil, + 0, + annotationsByTarget, + ) + + out := strings.Join(lines, "\n") + if !strings.Contains(out, "@annotation 'nested split note'") { + t.Fatalf("expected nested loop annotation in output:\n%s", out) + } +} + // ============================================================================= // collectErrorHandlerStatements // ============================================================================= From 9014cfbc8ac94d6449dbe96a829d520fa5080df4 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 08:10:45 +0200 Subject: [PATCH 2/2] test: add bug-test reproducer for loop-body annotation preservation Adds an MDL script under mdl-examples/bug-tests/ exercising an annotated IF nested inside a LOOP. After exec, the describe output must contain `@annotation 'note on nested if'`, confirming the builder propagates nested annotation flows to the parent graph and the describer collects captions recursively. Co-Authored-By: Claude Opus 4.7 --- .../330-preserve-loop-body-annotations.mdl | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl diff --git a/mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl b/mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl new file mode 100644 index 00000000..f08935f8 --- /dev/null +++ b/mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Bug #330: Annotations inside loop bodies disappeared after describe/exec +-- ============================================================================ +-- +-- Symptom (before fix): +-- `@annotation 'note'` attached to a statement nested inside `loop ... end loop;` +-- (or `while ... end while;`) survived the first execution but disappeared +-- on the next describe → exec roundtrip. The annotation existed in the +-- loop's local object collection but the parent microflow graph never +-- saw it, so the next describer pass dropped it. Annotations on nested +-- decisions (IF inside a LOOP) were the most visible casualty — the +-- notes that explain WHY a loop exists were silently lost. +-- +-- Root cause: +-- The microflow builder copied nested loop sequence flows back to the +-- parent graph but did not copy nested ANNOTATION flows. The describer +-- then collected annotation captions only from the top-level object +-- collection, ignoring captions stored inside nested loop collections. +-- +-- After fix: +-- - Builder: loop/while statement handlers also copy +-- `nested.AnnotationFlows` into the parent graph. +-- - Describer: `collectAnnotationCaptions` walks recursively into +-- nested loop object collections, and `emitLoopBody` merges the +-- loop-local annotation map into the per-body traversal. +-- +-- Scope note: +-- The Go-side regression `TestLoopBodyIfAnnotationPromotedToParentFlows` +-- covers the AST→BSON build path. This MDL script reproduces the +-- describer side: after exec, the annotation must appear in the +-- describe output. A full describe → exec → describe FIXPOINT for +-- nested IF inside LOOP additionally depends on the nearest split-merge +-- pairing fix (issue #326 / PR #327); on a branch that includes both +-- fixes, this script round-trips cleanly. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest330.MF_LoopWithAnnotations" +-- The describe output must contain `@annotation 'note on nested if'` +-- on the IF inside the loop body. +-- ============================================================================ + +create module BugTest330; + +create entity BugTest330.Item ( + Name : string(100) +); +/ + +-- LOOP body containing an annotated IF — the case that originally lost the +-- annotation. The note on the IF must appear in the describe output. +create microflow BugTest330.MF_LoopWithAnnotations ( + $Items: list of BugTest330.Item +) +begin + loop $Item in $Items + begin + @annotation 'note on nested if' + if $Item/Name != empty then + log info node 'BugTest330' 'has name'; + end if; + end loop; +end; +/