From 6be5f4317d72b33db1dee343ae0a6b8ba50d4f4a Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sun, 26 Apr 2026 11:47:07 +0200 Subject: [PATCH 1/6] fix: suppress default anchor fragments in describe Symptom: microflow describe emitted explicit @anchor fragments for flows that used the MDL default connection sides, creating cosmetic drift in round-trip output. Root cause: anchor formatting printed every known from/to side even when the value matched the statement default: regular flows use right-to-left, true branches use right-to-left, and false branches use bottom-to-top. Fix: omit default anchor fragments and skip the annotation entirely when all sides are defaults, while preserving explicit non-default sides. Tests: added regression coverage for omitted regular and split defaults, and adjusted existing split-anchor tests to assert that non-default branch anchors are still emitted. --- .../cmd_microflows_describe_anchor_test.go | 32 ++++++++++ ...cmd_microflows_describe_concurrent_test.go | 6 +- mdl/executor/cmd_microflows_show_helpers.go | 23 +++++-- ...d_microflows_split_incoming_anchor_test.go | 61 ++++++++++++++----- 4 files changed, 98 insertions(+), 24 deletions(-) diff --git a/mdl/executor/cmd_microflows_describe_anchor_test.go b/mdl/executor/cmd_microflows_describe_anchor_test.go index 011365f8..a18da17c 100644 --- a/mdl/executor/cmd_microflows_describe_anchor_test.go +++ b/mdl/executor/cmd_microflows_describe_anchor_test.go @@ -50,6 +50,38 @@ func TestEmitAnchorAnnotation_FromAndTo(t *testing.T) { } } +func TestEmitAnchorAnnotation_OmitsDefaultRightToLeft(t *testing.T) { + activity := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "act-default"}, + }, + }, + } + incoming := µflows.SequenceFlow{ + DestinationID: "act-default", + DestinationConnectionIndex: AnchorLeft, + } + outgoing := µflows.SequenceFlow{ + OriginID: "act-default", + OriginConnectionIndex: AnchorRight, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + "act-default": {outgoing}, + } + flowsByDest := map[model.ID][]*microflows.SequenceFlow{ + "act-default": {incoming}, + } + + var lines []string + emitAnchorAnnotation(activity, flowsByOrigin, flowsByDest, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected default anchor line to be omitted, got %v", lines) + } +} + func TestEmitAnchorAnnotation_NoFlowsSkipsEmission(t *testing.T) { activity := µflows.ActionActivity{ BaseActivity: microflows.BaseActivity{ diff --git a/mdl/executor/cmd_microflows_describe_concurrent_test.go b/mdl/executor/cmd_microflows_describe_concurrent_test.go index 9f2f3662..1bf06a8b 100644 --- a/mdl/executor/cmd_microflows_describe_concurrent_test.go +++ b/mdl/executor/cmd_microflows_describe_concurrent_test.go @@ -26,7 +26,7 @@ func TestFormatMicroflowActivities_Concurrent_NoRace(t *testing.T) { // Build two distinct microflows whose activities are anchored to // different sides. If the two describe calls share state, one will // emit the other's anchor keyword. - mfA := mkRaceMicroflow("mfa-start", "mfa-log", "mfa-end", AnchorRight) + mfA := mkRaceMicroflow("mfa-start", "mfa-log", "mfa-end", AnchorTop) mfB := mkRaceMicroflow("mfb-start", "mfb-log", "mfb-end", AnchorBottom) e := newTestExecutor() @@ -50,8 +50,8 @@ func TestFormatMicroflowActivities_Concurrent_NoRace(t *testing.T) { } wg.Wait() - wantA := "@anchor(from: right, to: left)" - wantB := "@anchor(from: bottom, to: left)" + wantA := "@anchor(from: top)" + wantB := "@anchor(from: bottom)" for i, got := range resultsA { if !strings.Contains(got, wantA) { t.Errorf("worker %d (A) missing %q in output:\n%s", i, wantA, got) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 3286eae3..c6d13631 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -141,12 +141,15 @@ func emitAnchorAnnotation( return } var parts []string - if from != "" { + if from != "" && from != "right" { parts = append(parts, "from: "+from) } - if to != "" { + if to != "" && to != "left" { parts = append(parts, "to: "+to) } + if len(parts) == 0 { + return + } *lines = append(*lines, indentStr+fmt.Sprintf("@anchor(%s)", strings.Join(parts, ", "))) } @@ -189,13 +192,13 @@ func emitSplitAnchorAnnotation( } var parts []string - if inTo != "" { + if inTo != "" && inTo != "left" { parts = append(parts, "to: "+inTo) } - if p := branchAnchorFragment("true", trueFrom, trueTo); p != "" { + if p := branchAnchorFragmentWithDefaults("true", trueFrom, trueTo, "right", "left"); p != "" { parts = append(parts, p) } - if p := branchAnchorFragment("false", falseFrom, falseTo); p != "" { + if p := branchAnchorFragmentWithDefaults("false", falseFrom, falseTo, "bottom", "top"); p != "" { parts = append(parts, p) } if len(parts) == 0 { @@ -220,6 +223,16 @@ func branchAnchorFragment(label, from, to string) string { return fmt.Sprintf("%s: (%s)", label, strings.Join(inner, ", ")) } +func branchAnchorFragmentWithDefaults(label, from, to, defaultFrom, defaultTo string) string { + if from == defaultFrom { + from = "" + } + if to == defaultTo { + to = "" + } + return branchAnchorFragment(label, from, to) +} + // emitLoopAnchorAnnotation emits the loop form of @anchor for a LoopedActivity. // A LoopedActivity has up to four flows worth describing: // - the incoming flow from the previous activity (normal `to:`) diff --git a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go index 8bb51263..a3ce841f 100644 --- a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go +++ b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go @@ -56,16 +56,16 @@ func TestEmitSplitAnchor_EmitsBranchAnchors(t *testing.T) { trueFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorRight, - DestinationConnectionIndex: AnchorLeft, + OriginConnectionIndex: AnchorTop, + DestinationConnectionIndex: AnchorTop, CaseValue: microflows.EnumerationCase{ Value: "true", }, } falseFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorTop, + OriginConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorRight, CaseValue: microflows.EnumerationCase{ Value: "false", }, @@ -84,8 +84,8 @@ func TestEmitSplitAnchor_EmitsBranchAnchors(t *testing.T) { out := lines[0] for _, want := range []string{ - "true: (from: right, to: left)", - "false: (from: bottom, to: top)", + "true: (from: top, to: top)", + "false: (from: left, to: right)", } { if !strings.Contains(out, want) { t.Errorf("output missing %q\nfull: %s", want, out) @@ -93,6 +93,35 @@ func TestEmitSplitAnchor_EmitsBranchAnchors(t *testing.T) { } } +func TestEmitSplitAnchor_OmitsDefaultBranchAnchors(t *testing.T) { + splitID := model.ID("split-defaults") + split := µflows.ExclusiveSplit{} + split.ID = splitID + + trueFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorRight, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + } + falseFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorTop, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + splitID: {trueFlow, falseFlow}, + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, nil, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected default branch anchor line to be omitted, got %v", lines) + } +} + func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { // Mendix splits often use ExpressionCase (Expression == "true" / "false") // instead of EnumerationCase. The anchor emission must identify the @@ -105,16 +134,16 @@ func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { trueFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorRight, - DestinationConnectionIndex: AnchorLeft, + OriginConnectionIndex: AnchorTop, + DestinationConnectionIndex: AnchorTop, CaseValue: µflows.ExpressionCase{ Expression: "true", }, } falseFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorTop, + OriginConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorRight, CaseValue: µflows.ExpressionCase{ Expression: "false", }, @@ -131,8 +160,8 @@ func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { } out := lines[0] for _, want := range []string{ - "true: (from: right, to: left)", - "false: (from: bottom, to: top)", + "true: (from: top, to: top)", + "false: (from: left, to: right)", } { if !strings.Contains(out, want) { t.Errorf("output missing %q\nfull: %s", want, out) @@ -155,8 +184,8 @@ func TestEmitSplitAnchor_SupportsBooleanCase(t *testing.T) { } falseFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorTop, + OriginConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorRight, CaseValue: µflows.BooleanCase{Value: false}, } flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ @@ -171,8 +200,8 @@ func TestEmitSplitAnchor_SupportsBooleanCase(t *testing.T) { } out := lines[0] for _, want := range []string{ - "true: (from: top, to: left)", - "false: (from: bottom, to: top)", + "true: (from: top)", + "false: (from: left, to: right)", } { if !strings.Contains(out, want) { t.Errorf("output missing %q\nfull: %s", want, out) From c16fa00e096570b0068b1c79214d189dc37a9e1c Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 00:00:26 +0200 Subject: [PATCH 2/6] test: add bug-test reproducer for default anchor suppression in describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an MDL script under mdl-examples/bug-tests/ verifying that linear flows produce zero @anchor lines (all defaults) while non-default sides like `to: top` survive a describe → exec → describe cycle. Co-Authored-By: Claude Opus 4.7 --- ...iber-suppress-default-anchor-fragments.mdl | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl diff --git a/mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl b/mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl new file mode 100644 index 00000000..6eb706b5 --- /dev/null +++ b/mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl @@ -0,0 +1,55 @@ +-- ============================================================================ +-- Bug #320: DESCRIBE emitted default anchor fragments redundantly +-- ============================================================================ +-- +-- Symptom (before fix): +-- `describe microflow` printed `@anchor` lines for every activity, even +-- when every from/to side matched the MDL default for that flow shape: +-- - regular sequence flows default to `from: right, to: left` +-- - true branches of IF default to `from: right, to: left` +-- - false branches of IF default to `from: bottom, to: top` +-- Output looked like +-- log info node 'X' 'a'; +-- @anchor(from: right, to: left) -- noise: this is the default +-- log info node 'X' 'b'; +-- producing verbose, non-author-friendly DESCRIBE output and noisy +-- `mxcli diff-local` runs after a roundtrip. +-- +-- After fix: +-- `emitAnchorAnnotation` and `emitSplitAnchorAnnotation` (in +-- cmd_microflows_show_helpers.go) suppress fragments that match the +-- default and skip the entire annotation when all sides are defaults. +-- Non-default sides (e.g. `to: top` on a return) are still emitted. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl -p app.mpr +-- +-- mxcli -p app.mpr -c "describe microflow BugTest320.MF_LinearDefaults" +-- The output must contain NO `@anchor` line — every flow uses defaults. +-- +-- mxcli -p app.mpr -c "describe microflow BugTest320.MF_NonDefaultReturn" +-- The output must keep `@anchor(to: top)` on the return statement. +-- ============================================================================ + +create module BugTest320; + +-- Linear flow, all defaults — describe must emit zero @anchor lines. +create microflow BugTest320.MF_LinearDefaults () +begin + log info node 'BugTest320' 'a'; + log info node 'BugTest320' 'b'; + log info node 'BugTest320' 'c'; +end; +/ + +-- One non-default `to: top` on the return — describe must keep that fragment. +create microflow BugTest320.MF_NonDefaultReturn ( + $value: integer +) +returns boolean as $ok +begin + log info node 'BugTest320' 'check'; + @anchor(to: top) + return $value > 0; +end; +/ From 5eb33702833fdb6bd5db6becacb64f97411106b9 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 19:47:56 +0200 Subject: [PATCH 3/6] fix: suppress builder-default split anchors Symptom: describe/exec/describe could add @anchor metadata to IF statements whose branch flows only used the layout that the microflow builder generated. Root cause: split anchor emission only treated one hard-coded true/false branch layout as default. IF statements without an ELSE use a different generated layout, with the true branch going down and the false branch continuing horizontally, so the describer exposed those generated anchors as authored metadata. Fix: suppress branch fragments when their from/to sides match either supported builder-default split layout, while still emitting non-default destination sides. Tests: added split-anchor regressions for the no-ELSE builder defaults and for a non-default destination anchor; ran make build, make lint-go, and make test. --- mdl/executor/cmd_microflows_show_helpers.go | 19 +++++-- ...d_microflows_split_incoming_anchor_test.go | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index c6d13631..9f85242f 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -195,10 +195,10 @@ func emitSplitAnchorAnnotation( if inTo != "" && inTo != "left" { parts = append(parts, "to: "+inTo) } - if p := branchAnchorFragmentWithDefaults("true", trueFrom, trueTo, "right", "left"); p != "" { + if p := branchAnchorFragmentWithDefaultSides("true", trueFrom, trueTo, []string{"right", "bottom"}, []string{"left"}); p != "" { parts = append(parts, p) } - if p := branchAnchorFragmentWithDefaults("false", falseFrom, falseTo, "bottom", "top"); p != "" { + if p := branchAnchorFragmentWithDefaultSides("false", falseFrom, falseTo, []string{"bottom", "right"}, []string{"top", "left"}); p != "" { parts = append(parts, p) } if len(parts) == 0 { @@ -223,16 +223,25 @@ func branchAnchorFragment(label, from, to string) string { return fmt.Sprintf("%s: (%s)", label, strings.Join(inner, ", ")) } -func branchAnchorFragmentWithDefaults(label, from, to, defaultFrom, defaultTo string) string { - if from == defaultFrom { +func branchAnchorFragmentWithDefaultSides(label, from, to string, defaultFroms, defaultTos []string) string { + if containsString(defaultFroms, from) { from = "" } - if to == defaultTo { + if containsString(defaultTos, to) { to = "" } return branchAnchorFragment(label, from, to) } +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + // emitLoopAnchorAnnotation emits the loop form of @anchor for a LoopedActivity. // A LoopedActivity has up to four flows worth describing: // - the incoming flow from the previous activity (normal `to:`) diff --git a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go index a3ce841f..ac7c5df3 100644 --- a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go +++ b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go @@ -122,6 +122,61 @@ func TestEmitSplitAnchor_OmitsDefaultBranchAnchors(t *testing.T) { } } +func TestEmitSplitAnchor_OmitsBuilderNoElseBranchAnchors(t *testing.T) { + splitID := model.ID("split-builder-defaults") + split := µflows.ExclusiveSplit{} + split.ID = splitID + + trueFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + } + falseFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorRight, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + splitID: {trueFlow, falseFlow}, + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, nil, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected builder-generated branch anchors to be omitted, got %v", lines) + } +} + +func TestEmitSplitAnchor_EmitsNonDefaultDestinationAgainstBuilderDefaults(t *testing.T) { + splitID := model.ID("split-non-default-destination") + split := µflows.ExclusiveSplit{} + split.ID = splitID + + trueFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorTop, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + splitID: {trueFlow}, + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, nil, &lines, "") + + if len(lines) != 1 { + t.Fatalf("expected non-default destination anchor to be emitted, got %v", lines) + } + if !strings.Contains(lines[0], "true: (to: top)") { + t.Fatalf("expected true branch destination anchor, got %q", lines[0]) + } +} + func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { // Mendix splits often use ExpressionCase (Expression == "true" / "false") // instead of EnumerationCase. The anchor emission must identify the From 2b5bac6a714c5eed54ff05966e510105a258f083 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 23:06:29 +0200 Subject: [PATCH 4/6] fix: suppress layout-equivalent branch anchors Symptom: describe/exec/describe still reported anchor-only drift for branch fragments such as false: (from: top), false: (to: bottom), and true: (to: bottom). The executor rebuilt equivalent flows, but the fixed default list made the first describe look more explicit than the second. Root cause: default branch anchors vary with the relative layout of split and branch destinations; suppressing only one fixed side pair left builder-equivalent single-sided fragments visible. Fix: treat those single-sided branch fragments as defaults only when the opposite side is already default, while preserving paired manual anchors such as false: (from: left, to: right). Tests: add split-anchor and IF describer regressions for the layout-equivalent fragments; make build and make test pass. --- mdl/executor/cmd_microflows_anchor_if_test.go | 45 +++++++++++++++ mdl/executor/cmd_microflows_show_helpers.go | 17 ++++++ ...d_microflows_split_incoming_anchor_test.go | 55 +++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/mdl/executor/cmd_microflows_anchor_if_test.go b/mdl/executor/cmd_microflows_anchor_if_test.go index 4caefbd7..d79fda39 100644 --- a/mdl/executor/cmd_microflows_anchor_if_test.go +++ b/mdl/executor/cmd_microflows_anchor_if_test.go @@ -10,9 +10,12 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" ) // buildWithAnchors is a test helper that builds the flow graph for a simple @@ -163,6 +166,48 @@ func TestBuilder_AnchorTrueBranchTo_EmptyThenIfWithElse(t *testing.T) { } } +func TestDescribe_FalseBranchFromTop_IfWithoutElseIsDefault(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}, + ThenBody: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "inside"}}, + }, + Annotations: &ast.ActivityAnnotations{ + FalseBranchAnchor: &ast.FlowAnchors{From: ast.AnchorSideTop, To: ast.AnchorSideUnset}, + }, + }, + &ast.ReturnStmt{}, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing} + oc := fb.buildFlowGraph(body, nil) + + var split microflows.MicroflowObject + for _, obj := range oc.Objects { + if _, ok := obj.(*microflows.ExclusiveSplit); ok { + split = obj + break + } + } + if split == nil { + t.Fatal("expected exclusive split") + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{} + flowsByDest := map[model.ID][]*microflows.SequenceFlow{} + for _, flow := range oc.Flows { + flowsByOrigin[flow.OriginID] = append(flowsByOrigin[flow.OriginID], flow) + flowsByDest[flow.DestinationID] = append(flowsByDest[flow.DestinationID], flow) + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, flowsByDest, &lines, "") + if len(lines) != 0 { + t.Fatalf("expected false branch from-top default anchor to be omitted, got %q", strings.Join(lines, "\n")) + } +} + func TestBuilder_AnchorToTopOnReturnPreservedInsideElse(t *testing.T) { // Minimal case: single-statement ELSE whose only statement is a RETURN // carrying @anchor(to: top). The flow from the split to that return's diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 9f85242f..cbbe9698 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -230,6 +230,23 @@ func branchAnchorFragmentWithDefaultSides(label, from, to string, defaultFroms, if containsString(defaultTos, to) { to = "" } + // Studio Pro / the builder can choose equivalent branch sides based on the + // relative layout of the branch destination. Suppress those single-sided + // fragments only when the opposite side is already default; paired manual + // anchors such as false: (from: left, to: right) still roundtrip visibly. + switch label { + case "false": + if to == "" && from == "top" { + from = "" + } + if from == "" && (to == "bottom" || to == "right") { + to = "" + } + case "true": + if from == "" && to == "bottom" { + to = "" + } + } return branchAnchorFragment(label, from, to) } diff --git a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go index ac7c5df3..5b61fff4 100644 --- a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go +++ b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go @@ -151,6 +151,61 @@ func TestEmitSplitAnchor_OmitsBuilderNoElseBranchAnchors(t *testing.T) { } } +func TestEmitSplitAnchor_OmitsSingleSidedLayoutEquivalentBranchAnchors(t *testing.T) { + tests := []struct { + name string + flow *microflows.SequenceFlow + }{ + { + name: "false from top", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorTop, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + }, + }, + { + name: "false to bottom", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorBottom, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + }, + }, + { + name: "false to right", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorRight, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + }, + }, + { + name: "true to bottom", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorRight, + DestinationConnectionIndex: AnchorBottom, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + splitID := model.ID("split-" + strings.ReplaceAll(tt.name, " ", "-")) + split := µflows.ExclusiveSplit{} + split.ID = splitID + tt.flow.OriginID = splitID + + var lines []string + emitAnchorAnnotation(split, map[model.ID][]*microflows.SequenceFlow{splitID: {tt.flow}}, nil, &lines, "") + if len(lines) != 0 { + t.Fatalf("expected layout-equivalent anchor to be omitted, got %v", lines) + } + }) + } +} + func TestEmitSplitAnchor_EmitsNonDefaultDestinationAgainstBuilderDefaults(t *testing.T) { splitID := model.ID("split-non-default-destination") split := µflows.ExclusiveSplit{} From 89ee0e8992c963a0be6364c8c209b1fdf1deea4e Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Tue, 28 Apr 2026 15:28:54 +0200 Subject: [PATCH 5/6] fix: suppress non-writable loop body tail anchors Symptom: describe could emit a statement-level from: anchor for a loop-body tail flow that exec cannot recreate without invalid Studio Pro sequence flows. Root cause: simple activity anchor emission treated the implicit body-to-loop tail edge as a writable outgoing flow on the body statement. Fix: when activity context is available, ignore outgoing body-to-loop tail flows for simple statement anchors; loop-level iterator/tail rendering remains unchanged. Tests: added a loop-body tail anchor emission regression and ran make test. --- .../cmd_microflows_describe_anchor_test.go | 47 ++++++++++++++++ mdl/executor/cmd_microflows_show_helpers.go | 54 ++++++++++++++++--- .../cmd_microflows_show_helpers_test.go | 2 +- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/mdl/executor/cmd_microflows_describe_anchor_test.go b/mdl/executor/cmd_microflows_describe_anchor_test.go index a18da17c..8a3f1e09 100644 --- a/mdl/executor/cmd_microflows_describe_anchor_test.go +++ b/mdl/executor/cmd_microflows_describe_anchor_test.go @@ -98,6 +98,53 @@ func TestEmitAnchorAnnotation_NoFlowsSkipsEmission(t *testing.T) { } } +func TestEmitAnchorAnnotation_IgnoresLoopBodyTailOutgoingFlow(t *testing.T) { + activity := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "body-act"}, + }, + }, + } + loop := µflows.LoopedActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "loop-1"}, + }, + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{activity}, + }, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + "body-act": { + { + OriginID: "body-act", + DestinationID: "loop-1", + OriginConnectionIndex: AnchorLeft, + }, + }, + } + flowsByDest := map[model.ID][]*microflows.SequenceFlow{ + "body-act": { + { + DestinationID: "body-act", + DestinationConnectionIndex: AnchorLeft, + }, + }, + } + activityMap := map[model.ID]microflows.MicroflowObject{ + "body-act": activity, + "loop-1": loop, + } + + var lines []string + emitAnchorAnnotationWithActivityMap(activity, flowsByOrigin, flowsByDest, activityMap, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected loop body tail flow to be ignored, got %v", lines) + } +} + func TestAnchorRoundtripViaParserBuilder(t *testing.T) { // Build an AST with an @anchor on a statement, run it through the builder, // and verify the resulting SequenceFlow has the right anchors. Then the diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index cbbe9698..04b80178 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -113,6 +113,17 @@ func emitAnchorAnnotation( flowsByDest map[model.ID][]*microflows.SequenceFlow, lines *[]string, indentStr string, +) { + emitAnchorAnnotationWithActivityMap(obj, flowsByOrigin, flowsByDest, nil, lines, indentStr) +} + +func emitAnchorAnnotationWithActivityMap( + obj microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + flowsByDest map[model.ID][]*microflows.SequenceFlow, + activityMap map[model.ID]microflows.MicroflowObject, + lines *[]string, + indentStr string, ) { id := obj.GetID() @@ -131,7 +142,13 @@ func emitAnchorAnnotation( var from, to string if outgoing := flowsByOrigin[id]; len(outgoing) > 0 { - from = anchorSideKeyword(outgoing[0].OriginConnectionIndex) + for _, flow := range outgoing { + if isNonWritableLoopBodyTailFlow(id, flow, activityMap) { + continue + } + from = anchorSideKeyword(flow.OriginConnectionIndex) + break + } } if incoming := flowsByDest[id]; len(incoming) > 0 { to = anchorSideKeyword(incoming[0].DestinationConnectionIndex) @@ -153,6 +170,22 @@ func emitAnchorAnnotation( *lines = append(*lines, indentStr+fmt.Sprintf("@anchor(%s)", strings.Join(parts, ", "))) } +func isNonWritableLoopBodyTailFlow(originID model.ID, flow *microflows.SequenceFlow, activityMap map[model.ID]microflows.MicroflowObject) bool { + if flow == nil || activityMap == nil { + return false + } + loop, ok := activityMap[flow.DestinationID].(*microflows.LoopedActivity) + if !ok || loop.ObjectCollection == nil { + return false + } + for _, obj := range loop.ObjectCollection.Objects { + if obj.GetID() == originID { + return true + } + } + return false +} + // emitSplitAnchorAnnotation emits the split form of @anchor — the incoming // `to: X` plus per-branch `true: (...)` / `false: (...)` — whenever any of the // three has a non-default value. The rendering matches the grammar accepted @@ -366,6 +399,7 @@ func emitObjectAnnotations( annotationsByTarget map[model.ID][]string, flowsByOrigin map[model.ID][]*microflows.SequenceFlow, flowsByDest map[model.ID][]*microflows.SequenceFlow, + activityMap map[model.ID]microflows.MicroflowObject, ) { currentID := obj.GetID() @@ -376,7 +410,7 @@ func emitObjectAnnotations( // @anchor — emit whenever attached flows exist, for roundtrip fidelity. // The emitter sorts out the right form (simple / split / loop) based on // the object type. - emitAnchorAnnotation(obj, flowsByOrigin, flowsByDest, lines, indentStr) + emitAnchorAnnotationWithActivityMap(obj, flowsByOrigin, flowsByDest, activityMap, lines, indentStr) } if activity, ok := obj.(*microflows.ActionActivity); ok { @@ -427,7 +461,7 @@ func emitActivityStatement( } // Emit @ annotations before the statement - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) currentID := obj.GetID() flows := flowsByOrigin[currentID] @@ -545,6 +579,10 @@ func traverseFlow( // Handle ExclusiveSplit specially - need to process both branches if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { startLine := len(*lines) + headerLineCount + if stmt != "" { + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) + *lines = append(*lines, indentStr+stmt) + } flows := flowsByOrigin[currentID] mergeID := splitMergeMap[currentID] @@ -638,7 +676,7 @@ func traverseFlow( if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { startLine := len(*lines) + headerLineCount if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -713,6 +751,10 @@ func traverseFlowUntilMerge( // Handle nested ExclusiveSplit if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { startLine := len(*lines) + headerLineCount + if stmt != "" { + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) + *lines = append(*lines, indentStr+stmt) + } flows := flowsByOrigin[currentID] nestedMergeID := splitMergeMap[currentID] @@ -802,7 +844,7 @@ func traverseFlowUntilMerge( if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { startLine := len(*lines) + headerLineCount if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -962,7 +1004,7 @@ func traverseLoopBody( if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { startLine := len(*lines) + headerLineCount if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } diff --git a/mdl/executor/cmd_microflows_show_helpers_test.go b/mdl/executor/cmd_microflows_show_helpers_test.go index 48731f06..b57efbad 100644 --- a/mdl/executor/cmd_microflows_show_helpers_test.go +++ b/mdl/executor/cmd_microflows_show_helpers_test.go @@ -149,7 +149,7 @@ func TestEmitObjectAnnotations_EscapesMultilineText(t *testing.T) { var lines []string // Pass nil flow maps — @anchor emission is intentionally suppressed here. - emitObjectAnnotations(obj, &lines, "", annotationsByTarget, nil, nil) + emitObjectAnnotations(obj, &lines, "", annotationsByTarget, nil, nil, nil) got := strings.Join(lines, "\n") if !strings.Contains(got, "@caption 'Caption\\nLine'") { From 2041812b39fde72014c95ff4590eef613b444df1 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 09:17:12 +0200 Subject: [PATCH 6/6] refactor: derive anchor default sides from anchorSideKeyword Replaces the hard-coded "right"/"left"/"top"/"bottom" string literals in emitAnchorAnnotation, emitSplitAnchorAnnotation, and branchAnchorFragmentWithDefaultSides with anchorSideKeyword() lookups so the suppression logic stays in lockstep with the keyword mapping if it ever changes. Adds an extended doc comment to branchAnchorFragmentWithDefaultSides explaining the two suppression passes and their order dependency. Addresses ako's review on PR #321. Co-Authored-By: Claude Opus 4.7 --- mdl/executor/cmd_microflows_show_helpers.go | 61 ++++++++++++++++----- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 04b80178..9f7a24b0 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -157,11 +157,13 @@ func emitAnchorAnnotationWithActivityMap( if from == "" && to == "" { return } + defaultFrom := anchorSideKeyword(AnchorRight) + defaultTo := anchorSideKeyword(AnchorLeft) var parts []string - if from != "" && from != "right" { + if from != "" && from != defaultFrom { parts = append(parts, "from: "+from) } - if to != "" && to != "left" { + if to != "" && to != defaultTo { parts = append(parts, "to: "+to) } if len(parts) == 0 { @@ -225,13 +227,18 @@ func emitSplitAnchorAnnotation( } var parts []string - if inTo != "" && inTo != "left" { + splitDefaultIn := anchorSideKeyword(AnchorLeft) + trueDefaultFroms := []string{anchorSideKeyword(AnchorRight), anchorSideKeyword(AnchorBottom)} + trueDefaultTos := []string{anchorSideKeyword(AnchorLeft)} + falseDefaultFroms := []string{anchorSideKeyword(AnchorBottom), anchorSideKeyword(AnchorRight)} + falseDefaultTos := []string{anchorSideKeyword(AnchorTop), anchorSideKeyword(AnchorLeft)} + if inTo != "" && inTo != splitDefaultIn { parts = append(parts, "to: "+inTo) } - if p := branchAnchorFragmentWithDefaultSides("true", trueFrom, trueTo, []string{"right", "bottom"}, []string{"left"}); p != "" { + if p := branchAnchorFragmentWithDefaultSides("true", trueFrom, trueTo, trueDefaultFroms, trueDefaultTos); p != "" { parts = append(parts, p) } - if p := branchAnchorFragmentWithDefaultSides("false", falseFrom, falseTo, []string{"bottom", "right"}, []string{"top", "left"}); p != "" { + if p := branchAnchorFragmentWithDefaultSides("false", falseFrom, falseTo, falseDefaultFroms, falseDefaultTos); p != "" { parts = append(parts, p) } if len(parts) == 0 { @@ -256,6 +263,29 @@ func branchAnchorFragment(label, from, to string) string { return fmt.Sprintf("%s: (%s)", label, strings.Join(inner, ", ")) } +// branchAnchorFragmentWithDefaultSides returns the `label: (from: X, to: Y)` +// fragment for a branch anchor, suppressing sides that match the layout +// default and removing the whole fragment when both sides reduce to default. +// +// The function applies suppression in two passes: +// +// 1. Primary suppression — if `from` or `to` is one of the documented +// defaults for this branch (e.g. true branch defaults to from=right or +// from=bottom and to=left), zero it out. +// +// 2. Secondary suppression — when ONE side has already been zeroed by pass 1, +// check whether the surviving side is itself a layout-equivalent default +// that Studio Pro auto-routes. The combinations were observed against +// real Studio Pro output: e.g. on a false branch with no FROM, Studio Pro +// routes to bottom or right automatically; on a true branch with no FROM, +// Studio Pro routes to bottom when the target sits below the split. +// Suppressing these prevents the describer from emitting fragments that +// Studio Pro would have layered identically anyway. +// +// The secondary pass is intentionally order-dependent: it relies on `from` and +// `to` being post-primary-suppression. Paired manual anchors like +// `false: (from: left, to: right)` survive both passes because neither side +// was zeroed by pass 1. func branchAnchorFragmentWithDefaultSides(label, from, to string, defaultFroms, defaultTos []string) string { if containsString(defaultFroms, from) { from = "" @@ -263,20 +293,21 @@ func branchAnchorFragmentWithDefaultSides(label, from, to string, defaultFroms, if containsString(defaultTos, to) { to = "" } - // Studio Pro / the builder can choose equivalent branch sides based on the - // relative layout of the branch destination. Suppress those single-sided - // fragments only when the opposite side is already default; paired manual - // anchors such as false: (from: left, to: right) still roundtrip visibly. + // Secondary suppression: see function comment above for the reasoning. + // Inputs to this switch are already post-primary-suppression. + top := anchorSideKeyword(AnchorTop) + bottom := anchorSideKeyword(AnchorBottom) + right := anchorSideKeyword(AnchorRight) switch label { case "false": - if to == "" && from == "top" { + if to == "" && from == top { from = "" } - if from == "" && (to == "bottom" || to == "right") { + if from == "" && (to == bottom || to == right) { to = "" } case "true": - if from == "" && to == "bottom" { + if from == "" && to == bottom { to = "" } } @@ -601,7 +632,7 @@ func traverseFlow( } if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -771,7 +802,7 @@ func traverseFlowUntilMerge( } if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -924,7 +955,7 @@ func traverseLoopBody( } if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) }