Skip to content
Merged

Pull #531

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
1 change: 1 addition & 0 deletions .claude/skills/fix-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ to the symptom table below, so the next similar issue costs fewer reads.
| MDL check gives "unexpected token" on valid-looking syntax | Grammar missing rule or token | `mdl/grammar/MDLParser.g4` + `MDLLexer.g4` | Add rule/token, run `make grammar` |
| CE7054 "parameters updated" / CE7067 "does not support body entity" after `send rest request` | `addSendRestRequestAction` emitted wrong BSON: all params as query params, BodyVariable set for JSON bodies | `mdl/executor/cmd_microflows_builder_calls.go` → `addSendRestRequestAction` | Look up operation via `fb.restServices`; route path/query params with `buildRestParameterMappings`; suppress BodyVariable for JSON/TEMPLATE/FILE via `shouldSetBodyVariable` |
| `CREATE X` returns "already exists — use create or replace to overwrite" but OR REPLACE is not valid for that type | Error message in executor points to wrong keyword | `mdl/executor/cmd_<type>_*.go` — find the `NewAlreadyExistsMsg` call | Change hint from `or replace` to `or modify`; verify the AST stmt uses `CreateOrModify` not `CreateOrReplace` |
| `DESCRIBE microflow` puts shared activities inside an `if … then` block — they should appear after `end if;` | Nested guard split inside `traverseFlowUntilMerge` crosses the outer merge boundary | `mdl/executor/cmd_microflows_show_helpers.go` — guard path in `traverseFlowUntilMerge` (~line 854) | Add `if contID != mergeID` guard before the `isMerge` skip-through so the guard continuation never crosses the outer merge |

---

Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/mendix/write-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ end split;

`case` values are qualified entity names. The optional `else` branch handles objects that do not match any listed specialization.

**`cast` only stores the output variable.** Studio Pro persists Microflows$CastAction with a single `VariableName` field — the source variable is implicit (the type-split's input). Use `cast $SpecificName;` to give the specialized variable its name. The two-variable form `$Output = cast $Source;` parses but `$Source` is dropped on roundtrip; prefer the single-variable form.

### LOOP Statements

```mdl
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-- ============================================================================
-- Bug #475: CE0079 on inheritance split when one branch continues
-- ============================================================================
--
-- Symptom (before fix):
-- Studio Pro `mx check` reports CE0079 "Activity has no outgoing flow"
-- on the body of a terminating branch in a `split type` whose other
-- branch continues to a follow-up activity. mxcli's
-- `addStructuredInheritanceSplit` took a "no-merge shortcut" when
-- exactly one non-split branch continued: it wired the parent's next
-- statement directly to the continuing branch's tail and skipped the
-- ExclusiveMerge. The terminating branch's tail then had nowhere to
-- converge — Studio Pro flagged its outgoing-flow gap as CE0079.
-- On re-describe the parent's follow-up activity also leaked inside
-- the case body, breaking subsequent roundtrips.
--
-- After fix:
-- `addStructuredInheritanceSplit` always emits an ExclusiveMerge when
-- any branch continues, mirroring the invariant `addEnumSplit`
-- already enforces. Both terminating and continuing branches converge
-- on the merge; the parent's next statement attaches after the merge.
--
-- Validation:
-- `mxcli check` parses the script.
-- `mx check` against the resulting MPR reports 0 errors.
-- Roundtrip (describe → exec → describe) preserves the structure
-- byte-for-byte: the post-split log activity stays outside both case
-- bodies.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/475-inheritance-split-continuing-branch-merge.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe microflow BugTest475.MF_TypedDispatch"
-- ============================================================================

create module BugTest475;

create persistent entity BugTest475.Vehicle (
Plate : string
);
/

create persistent entity BugTest475.Car
extends BugTest475.Vehicle (
Wheels : integer
);
/

create persistent entity BugTest475.Boat
extends BugTest475.Vehicle (
HullLength : decimal
);
/

-- One case continues into the post-split log activity; the other case
-- terminates with a `return`. Before the fix this combination tripped
-- CE0079 on the boat case. After the fix the merge converges both tails
-- and the log activity attaches after the merge.
create microflow BugTest475.MF_TypedDispatch (
$obj : BugTest475.Vehicle
)
returns boolean
begin
split type $obj
case BugTest475.Car
log info node 'BugTest475' 'Dispatching car';
case BugTest475.Boat
log info node 'BugTest475' 'Dispatching boat';
return false;
end split;
log info node 'BugTest475' 'Dispatched';
return true;
end;
/
46 changes: 46 additions & 0 deletions mdl-examples/bug-tests/528-nested-guard-shared-activities.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- Bug test for issue #528:
-- Microflow MDL extraction is faulty — shared activities after an outer
-- if/merge are incorrectly placed inside the then-block when a nested guard
-- split's false path leads directly to the outer ExclusiveMerge.
--
-- Verifies:
-- 1. When an inner guard (PipelineDate empty check) has its false path going
-- straight to the outer ExclusiveMerge, the activities that follow the
-- merge (change $Deal, DuplicatePressed handling) appear OUTSIDE the
-- outer `if $Pipeline = true then … end if;` block — not inside it.
--
-- Reported by: jwinckelmann (Studio Pro v10.24.13, mxcli v0.8.0)
-- Root cause: traverseFlowUntilMerge's guard continuation was skipping
-- through the outer ExclusiveMerge and swallowing the shared
-- activities, leaving nothing to emit after `end if;`.

-- This script cannot be executed directly because it describes a microflow
-- that only exists in the reporter's project. The MDL below is the EXPECTED
-- output from `DESCRIBE MICROFLOW TCUApp.ACT_Deal_SaveAsVRIPipeline` after
-- the fix is applied — shared activities appear after `end if;`:

-- if $Deal/Pipeline = true then
-- if $Deal/PipelineDate = empty then
-- show message ...;
-- log info ...;
-- return;
-- end if;
-- end if;
-- change $Deal (...);
-- if $Deal/DuplicatePressed then
-- ...
-- end if;
--
-- The broken output (before fix) placed `change $Deal` INSIDE the outer if:
--
-- if $Deal/Pipeline = true then
-- if $Deal/PipelineDate = empty then
-- ...
-- return;
-- end if;
-- change $Deal (...); ← wrong: should be outside the outer if
-- if $Deal/DuplicatePressed then
-- ...
-- end if;
-- end if;
-- ← nothing here: shared acts were swallowed
3 changes: 0 additions & 3 deletions mdl/executor/cmd_microflows_builder_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -594,9 +594,6 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt
fb.endsWithReturn = savedEndsWithReturn
if allBranchesReturn {
fb.endsWithReturn = true
} else if len(branchTails) == 1 && !branchTails[0].fromSplit {
fb.nextConnectionPoint = branchTails[0].id
fb.nextFlowCase = branchTails[0].caseValue
} else if len(branchTails) > 0 {
merge := &microflows.ExclusiveMerge{
BaseMicroflowObject: microflows.BaseMicroflowObject{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/sdk/microflows"
)

// TestInheritanceSplitAlwaysEmitsMergeWhenBranchContinues guards against a
// describe/exec roundtrip regression where `addStructuredInheritanceSplit`
// used to take a "no-merge shortcut" when exactly one non-split branch
// continued: it wired the parent's next statement directly to the
// continuing case's tail. Two things broke:
//
// 1. Re-describe emitted the parent's continuation inside the case body
// (visually burying statements in the wrong scope).
// 2. Studio Pro raised CE0079 ("condition value should be configured for
// an outgoing flow") on terminating branches because their cases had
// no merge to converge on.
func TestInheritanceSplitAlwaysEmitsMergeWhenBranchContinues(t *testing.T) {
fb := &flowBuilder{
spacing: HorizontalSpacing,
measurer: &layoutMeasurer{},
}

// An InheritanceSplit with one continuing case (`CastedA`) and a
// terminating else that returns. Before the fix this took the no-merge
// shortcut because branchTails == 1 && !fromSplit.
fb.addStructuredInheritanceSplit(&ast.InheritanceSplitStmt{
Variable: "obj",
Cases: []ast.InheritanceSplitCase{
{
Entity: ast.QualifiedName{Module: "M", Name: "CastedA"},
Body: []ast.MicroflowStatement{
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "continue"}},
},
},
},
ElseBody: []ast.MicroflowStatement{
&ast.ReturnStmt{},
},
})

var merge *microflows.ExclusiveMerge
for _, obj := range fb.objects {
if m, ok := obj.(*microflows.ExclusiveMerge); ok {
merge = m
}
}
if merge == nil {
t.Fatal("expected ExclusiveMerge to be created when one branch continues — no-merge shortcut regression")
}
}
19 changes: 12 additions & 7 deletions mdl/executor/cmd_microflows_show_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,17 +881,22 @@ func traverseFlowUntilMerge(
*lines = append(*lines, indentStr+"end if;")
recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1)

// Continue from the false branch (skip through merge if present)
// Continue from the false branch (skip through merge if present).
// Guard: do not cross the outer merge boundary — if the false path
// leads directly to mergeID, stop here so the activities after the
// merge are emitted by continueAfterSplitJoin, not inside this branch.
if falseFlow != nil {
contID := falseFlow.DestinationID
if _, isMerge := activityMap[contID].(*microflows.ExclusiveMerge); isMerge {
visited[contID] = true
for _, flow := range flowsByOrigin[contID] {
contID = flow.DestinationID
break
if contID != mergeID {
if _, isMerge := activityMap[contID].(*microflows.ExclusiveMerge); isMerge {
visited[contID] = true
for _, flow := range flowsByOrigin[contID] {
contID = flow.DestinationID
break
}
}
traverseFlowUntilMerge(ctx, contID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
}
traverseFlowUntilMerge(ctx, contID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
}
} else {
if trueFlow != nil {
Expand Down
98 changes: 98 additions & 0 deletions mdl/executor/cmd_microflows_traverse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1361,3 +1361,101 @@ func TestTraverseFlow_BothBranchesToMerge_NoSwap(t *testing.T) {
t.Errorf("expected no negation when both branches go to merge, got:\n%s", output)
}
}

// TestTraverseFlow_Issue528_NestedGuardDoesNotSwallowSharedActivities is a
// regression test for issue #528.
//
// Structure (matches TCUApp.ACT_Deal_SaveAsVRIPipeline):
//
// outerSplit (condition: $Pipeline=true)
// [true] → innerGuardSplit (guard: $PipelineDate=empty, same Y, RIGHT→LEFT false path)
// [true=true] → showMsg → innerReturn (EndEvent)
// [false=false] → outerMerge (directly; same Y as innerGuardSplit)
// [false=false] → outerMerge
// ↓
// sharedAct (change $Deal — must be OUTSIDE the outer if)
// ↓
// end (EndEvent)
//
// Before the fix the guard continuation in traverseFlowUntilMerge would
// "skip through" outerMerge and swallow sharedAct inside the outer then-block,
// leaving nothing to emit after `end if;`.
func TestTraverseFlow_Issue528_NestedGuardDoesNotSwallowSharedActivities(t *testing.T) {
e := newTestExecutor()

// inner guard split: same Y as outerSplit so its false path looks like a
// guard continuation (flowLooksLikeGuardContinuation relies on Y equality
// and RIGHT→LEFT connection indices).
innerGuardFalseFlow := mkBranchFlow("inner_guard", "outer_merge", &microflows.ExpressionCase{Expression: "false"})
innerGuardFalseFlow.OriginConnectionIndex = AnchorRight
innerGuardFalseFlow.DestinationConnectionIndex = AnchorLeft

mkObjAt := func(id string, x, y int) microflows.BaseMicroflowObject {
return microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: mkID(id)},
Position: model.Point{X: x, Y: y},
}
}

activityMap := map[model.ID]microflows.MicroflowObject{
mkID("start"): &microflows.StartEvent{BaseMicroflowObject: mkObjAt("start", 0, 60)},
mkID("outer_split"): &microflows.ExclusiveSplit{
BaseMicroflowObject: mkObjAt("outer_split", 50, 60),
SplitCondition: &microflows.ExpressionSplitCondition{Expression: "$Pipeline = true"},
},
mkID("inner_guard"): &microflows.ExclusiveSplit{
BaseMicroflowObject: mkObjAt("inner_guard", 150, 60),
SplitCondition: &microflows.ExpressionSplitCondition{Expression: "$PipelineDate = empty"},
},
mkID("show_msg"): &microflows.ActionActivity{
BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObjAt("show_msg", 150, 160)},
Action: &microflows.LogMessageAction{LogLevel: "Warning", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "date required"}}},
},
mkID("inner_return"): &microflows.EndEvent{BaseMicroflowObject: mkObjAt("inner_return", 150, 260)},
mkID("outer_merge"): &microflows.ExclusiveMerge{BaseMicroflowObject: mkObjAt("outer_merge", 300, 60)},
mkID("shared_act"): &microflows.ActionActivity{
BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObjAt("shared_act", 400, 60)},
Action: &microflows.CommitObjectsAction{CommitVariable: "Deal"},
},
mkID("end"): &microflows.EndEvent{BaseMicroflowObject: mkObjAt("end", 500, 60)},
}

flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{
mkID("start"): {mkFlow("start", "outer_split")},
mkID("outer_split"): {
mkBranchFlow("outer_split", "inner_guard", &microflows.ExpressionCase{Expression: "true"}),
mkBranchFlow("outer_split", "outer_merge", &microflows.ExpressionCase{Expression: "false"}),
},
mkID("inner_guard"): {
mkBranchFlow("inner_guard", "show_msg", &microflows.ExpressionCase{Expression: "true"}),
innerGuardFalseFlow,
},
mkID("show_msg"): {mkFlow("show_msg", "inner_return")},
mkID("outer_merge"): {mkFlow("outer_merge", "shared_act")},
mkID("shared_act"): {mkFlow("shared_act", "end")},
}

splitMergeMap := map[model.ID]model.ID{
mkID("outer_split"): mkID("outer_merge"),
// inner_guard has no merge: its true branch terminates, false goes to outer_merge
}

var lines []string
visited := map[model.ID]bool{}
e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, splitMergeMap, visited, nil, nil, &lines, 0, nil, 0, nil)

out := strings.Join(lines, "\n")

// shared_act must appear AFTER `end if;`, not inside the outer if block.
endIfIdx := strings.Index(out, "end if;")
sharedIdx := strings.Index(out, "commit $Deal;")
if endIfIdx < 0 {
t.Fatalf("expected 'end if;' in output, got:\n%s", out)
}
if sharedIdx < 0 {
t.Fatalf("expected 'commit $Deal;' in output, got:\n%s", out)
}
if sharedIdx <= endIfIdx {
t.Errorf("issue #528: shared activity emitted inside outer if block instead of after end if;\n%s", out)
}
}
Loading
Loading