Skip to content

Commit 1f8864a

Browse files
committed
feat: @anchor annotation for microflow sequence flow endpoints
Studio Pro renders each SequenceFlow against one of four sides of the origin and destination activity boxes (top, right, bottom, left). Until now the builder always derived the anchor side from the flow's visual direction and the describer dropped the information entirely, so a DESCRIBE → re-execute round-trip lost every manual side-tweak. Agent- generated microflow patches therefore reshuffled the arrow layout in the Studio Pro diagram even when they were semantically identical to the original — the flow diagram had to be re-tidied by hand after each iteration. New optional annotation on microflow statements: @anchor(from: right, to: left) log info node 'App' 'hello'; Each side is independently optional. Missing sides fall back to the builder's default for the visual flow direction — existing MDL scripts are unchanged. IF statements support a combined form for the incoming flow plus the per-branch outgoing flows: @anchor(to: top, true: (from: right, to: left), false: (from: bottom, to: top)) if condition then ... else ... end if; top (0), right (1), bottom (2), left (3) — matching the indices Mendix stores in OriginConnectionIndex / DestinationConnectionIndex on the SequenceFlow BSON. @anchor is emitted for every activity whenever flows are attached, so describe → exec → describe is bit-exact on the anchor indices. For splits the combined form is used when any of the three groups (incoming to, true-branch, false-branch) has a non-default value. - AST: ast.AnchorSide, ast.FlowAnchors; ActivityAnnotations gains Anchor / TrueBranchAnchor / FalseBranchAnchor / IteratorAnchor / BodyTailAnchor (all optional, AST-only for iterator/tail). - Grammar: ANCHOR / TOP / BOTTOM lexer tokens; `@anchor(key: value)` parser rule with a nested-params form for the split-branch shape. - Visitor: populates anchor fields from annotation contexts. - Builder: applyUserAnchors helper; mergeStatementAnnotations carries anchor fields into pendingAnnotations; buildFlowGraph and addIfStatement apply anchor indices per the user spec. Guard-pattern IFs (no else, then returns) use the new nextFlowAnchor plumbing so the deferred split→nextActivity flow also honours @anchor(false: ...). - Describer: emitAnchorAnnotation outputs `@anchor(from: X, to: Y)` for non-split activities and a split-form variant for ExclusiveSplit / InheritanceSplit. Branch detection reuses findBranchFlows so all CaseValue variants (ExpressionCase, EnumerationCase, BooleanCase, value + pointer) are covered. Tightening @anchor roundtrips exposed two pre-existing bugs in expressionToString used for `if cond then`, regex literals, etc.: - Using mdlQuote doubled every backslash, breaking regex escape sequences like \d that the Mendix expression engine consumes literally. - Using only apostrophe-doubling emitted raw control characters (0x0A, 0x0D, 0x09) inside single-quoted literals, which STRING_LITERAL rejects. Fix: new quoteExpressionLiteral that escapes control chars and backslashes followed by an MDL-significant letter (n/r/t/\/'), but passes other backslash sequences through verbatim. Trailing backslash at EOF is doubled so the lexer doesn't consume the closing quote as an escape pair. Loop describe output previously omitted the `begin` keyword the MDL grammar requires after `loop $X in $Y`. It parsed by accident because every body statement's leading keyword was accepted after the header. With @anchor landing before the first body statement, the parser started seeing `@position(...)` immediately after the loop header and failed. Fix: emit `begin` unconditionally between the loop header and body in every LoopedActivity call-site. - visitor_anchor_test.go: syntax simple / partial / 4 sides / split form / absence → nil. - cmd_microflows_builder_anchor_test.go: override applied; default kept when omitted; partial override preserves other side; per-branch IF anchors. - cmd_microflows_describe_anchor_test.go: @anchor emission; no-flows case; roundtrip via builder. - cmd_microflows_split_incoming_anchor_test.go: split incoming to; EnumerationCase, ExpressionCase, BooleanCase branch forms. - cmd_microflows_expr_literal_escape_test.go: regex passthrough; raw control char escaping; apostrophe doubling; backslash-before-escape letter doubling; trailing backslash doubling; idempotency. - cmd_microflows_anchor_if_test.go: anchor preservation inside ELSE branch (real mendixlabs#35 pattern); anchor(to: top) on return inside else. - cmd_microflows_annotation_escape_test.go: mdlQuote control-char escaping; apostrophe doubling. - cmd_microflows_guard_pattern_test.go: guard-pattern IF carries false-branch anchor through the deferred flow. - cmd_microflows_loop_begin_test.go: loop describe output opens with `begin`. Tested against the Control Centre project (Mendix 9.24, ~200 microflows). On 5 representative cases including the attempt-mendixlabs#35 repro (Apps.GetOrCreateMendixVersionFromString, MxAdmin.CreateMendixVersion FromString, AcademyIntegration.GetOrCreateCertificate), the describe → exec → describe round-trip preserves every anchor (0 drifts) and the output parses through `mxcli check`. CHANGELOG updated under [Unreleased] → Added.
1 parent 0235cdb commit 1f8864a

32 files changed

Lines changed: 16415 additions & 14075 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1313
- **Path normalization** — Relative paths in `MetadataUrl` are automatically converted to absolute `file://` URLs for Studio Pro compatibility
1414
- **ServiceUrl validation**`ServiceUrl` parameter must now be a constant reference (e.g., `@Module.ConstantName`) to enforce Mendix best practice
1515
- **Shared URL utilities**`internal/pathutil` package with `NormalizeURL()`, `URIToPath()`, and `PathFromURL()` for reuse across components
16+
- **@anchor sequence flow annotation** — optional `@anchor(from: X, to: Y)` annotation on microflow statements that pins the side of the activity box a SequenceFlow attaches to (top / right / bottom / left). Split (`if`) statements support the combined form `@anchor(to: X, true: (from: ..., to: ...), false: (from: ..., to: ...))` so the incoming and per-branch outgoing anchors survive describe → exec round-trip. When omitted, the builder derives the anchor from the visual flow direction (existing behaviour is unchanged). Keeps the flow diagram stable across patches when an agent-generated microflow is applied back to a project
1617

1718
### Changed
1819

cmd/mxcli/lsp_completions_gen.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
-- ============================================================================
2+
-- Feature: @anchor annotation for sequence flow endpoints
3+
-- ============================================================================
4+
--
5+
-- Motivation:
6+
-- Studio Pro renders SequenceFlows connecting to one of the four sides of
7+
-- each activity box (top, right, bottom, left). Until this annotation was
8+
-- added, the builder always chose the anchor side from the flow direction
9+
-- (horizontal → right-to-left, upward → right-to-bottom, etc.) and the
10+
-- describer dropped the information entirely. A DESCRIBE → re-execute
11+
-- round-trip therefore lost every manual anchor adjustment and the flow
12+
-- diagram had to be re-tidied by hand after each iteration.
13+
--
14+
-- Syntax:
15+
-- @anchor(from: X, to: Y) — anchors for the single outgoing flow
16+
-- @anchor(true: (from:..., to:...), false: (from:..., to:...)) — IF
17+
--
18+
-- Each side is independently optional. Missing sides fall back to the
19+
-- builder's default for the visual flow direction.
20+
--
21+
-- Usage:
22+
-- mxcli exec mdl-examples/bug-tests/anchor-sequence-flow-annotation.mdl -p app.mpr
23+
-- Then: mxcli describe microflow BugTestAnchor.MF_CustomAnchors -p app.mpr
24+
-- The describe output must include the @anchor(...) lines below verbatim.
25+
-- ============================================================================
26+
27+
create module BugTestAnchor;
28+
29+
create microflow BugTestAnchor.MF_CustomAnchors ()
30+
returns string as $result
31+
begin
32+
declare $result string = empty;
33+
34+
@anchor(from: bottom)
35+
log info node 'App' 'first';
36+
37+
@anchor(to: top)
38+
log info node 'App' 'second';
39+
40+
set $result = 'done';
41+
return $result;
42+
end;
43+
/
44+
45+
create microflow BugTestAnchor.MF_IfBranchAnchors ()
46+
returns string as $result
47+
begin
48+
declare $result string = empty;
49+
50+
@anchor(true: (from: right, to: left), false: (from: bottom, to: top))
51+
if true then
52+
set $result = 'yes';
53+
else
54+
set $result = 'no';
55+
end if;
56+
57+
return $result;
58+
end;
59+
/

mdl/ast/ast_microflow.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,48 @@ type RaiseErrorStmt struct {
105105

106106
func (s *RaiseErrorStmt) isMicroflowStatement() {}
107107

108+
// AnchorSide identifies one of the four sides of an activity's visual box
109+
// that a SequenceFlow attaches to.
110+
type AnchorSide int
111+
112+
const (
113+
AnchorSideUnset AnchorSide = -1
114+
AnchorSideTop AnchorSide = 0
115+
AnchorSideRight AnchorSide = 1
116+
AnchorSideBottom AnchorSide = 2
117+
AnchorSideLeft AnchorSide = 3
118+
)
119+
120+
// FlowAnchors captures the origin/destination anchors for a single
121+
// SequenceFlow. Each side is independently optional: Unset means the builder
122+
// should derive the anchor from the visual direction.
123+
type FlowAnchors struct {
124+
From AnchorSide // OriginConnectionIndex on the outgoing SequenceFlow
125+
To AnchorSide // DestinationConnectionIndex on the outgoing SequenceFlow
126+
}
127+
108128
// ActivityAnnotations holds metadata annotations for microflow activities.
109-
// These are emitted as @position, @caption, @color, @annotation, @excluded lines in MDL.
129+
// These are emitted as @position, @caption, @color, @annotation, @excluded, @anchor lines in MDL.
110130
type ActivityAnnotations struct {
111-
Position *Position // @position(x, y)
112-
Caption string // @caption 'text'
113-
Color string // @color Green
114-
AnnotationText string // @annotation 'text'
115-
Excluded bool // @excluded
131+
Position *Position // @position(x, y)
132+
Caption string // @caption 'text'
133+
Color string // @color Green
134+
AnnotationText string // @annotation 'text'
135+
Excluded bool // @excluded
136+
Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement
137+
138+
// Split-specific anchors for IF statements. When the statement is not an
139+
// IF these remain nil. The grammar accepts them on IfStmt only:
140+
// @anchor(true: (from: right, to: left), false: (from: bottom, to: left))
141+
TrueBranchAnchor *FlowAnchors
142+
FalseBranchAnchor *FlowAnchors
143+
144+
// Loop body anchors for LOOP/WHILE. IteratorAnchor is the flow that
145+
// enters the loop body from the iterator; BodyTailAnchor is the flow
146+
// from the last body statement back to the loop boundary. Both are only
147+
// populated on LoopStmt/WhileStmt.
148+
IteratorAnchor *FlowAnchors
149+
BodyTailAnchor *FlowAnchors
116150
}
117151

118152
// ChangeItem represents a single assignment in CREATE/CHANGE: Attr = expr
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Regression tests for anchor preservation inside IF branches — the original
4+
// @anchor implementation only handled the top-level flow between statements
5+
// at the microflow body level. Anchors on statements inside THEN/ELSE bodies
6+
// (including the flow between a branch's first statement and its successors,
7+
// and the flow leaving the last branch statement to the merge) were silently
8+
// dropped, so real-world microflows like the attempt #35 repro case lost
9+
// every vertical anchor on roundtrip.
10+
package executor
11+
12+
import (
13+
"testing"
14+
15+
"github.com/mendixlabs/mxcli/mdl/ast"
16+
)
17+
18+
// buildWithAnchors is a test helper that builds the flow graph for a simple
19+
// microflow body and returns the collection for inspection.
20+
func buildWithAnchors(body []ast.MicroflowStatement) (oc *struct {
21+
Flows []anchorFlow
22+
Objects int
23+
}) {
24+
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
25+
col := fb.buildFlowGraph(body, nil)
26+
oc = &struct {
27+
Flows []anchorFlow
28+
Objects int
29+
}{
30+
Objects: len(col.Objects),
31+
}
32+
for _, f := range col.Flows {
33+
oc.Flows = append(oc.Flows, anchorFlow{
34+
OriginIdx: f.OriginConnectionIndex,
35+
DestIdx: f.DestinationConnectionIndex,
36+
})
37+
}
38+
return oc
39+
}
40+
41+
type anchorFlow struct {
42+
OriginIdx int
43+
DestIdx int
44+
}
45+
46+
// hasFlow returns true when at least one flow has the given anchor pair.
47+
func hasFlow(flows []anchorFlow, origin, dest int) bool {
48+
for _, f := range flows {
49+
if f.OriginIdx == origin && f.DestIdx == dest {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
func TestBuilder_AnchorInsideElseBranch(t *testing.T) {
57+
// Reproduces the pattern from attempt #35:
58+
// if cond then { set ... }
59+
// else {
60+
// @anchor(from: bottom, to: top)
61+
// log ...
62+
// @anchor(to: top)
63+
// return empty
64+
// }
65+
body := []ast.MicroflowStatement{
66+
&ast.IfStmt{
67+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
68+
ThenBody: []ast.MicroflowStatement{
69+
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "a"}},
70+
},
71+
ElseBody: []ast.MicroflowStatement{
72+
&ast.LogStmt{
73+
Level: ast.LogInfo,
74+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "b"},
75+
Annotations: &ast.ActivityAnnotations{
76+
Anchor: &ast.FlowAnchors{From: ast.AnchorSideBottom, To: ast.AnchorSideTop},
77+
},
78+
},
79+
&ast.ReturnStmt{
80+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "done"},
81+
Annotations: &ast.ActivityAnnotations{
82+
Anchor: &ast.FlowAnchors{To: ast.AnchorSideTop, From: ast.AnchorSideUnset},
83+
},
84+
},
85+
},
86+
},
87+
}
88+
89+
oc := buildWithAnchors(body)
90+
91+
// The else-first-statement flow must land on the log's top anchor.
92+
if !hasFlow(oc.Flows, AnchorBottom, AnchorTop) {
93+
t.Errorf("expected split→log flow with Bottom→Top (from user's own @anchor), got %+v", oc.Flows)
94+
}
95+
// The log→return flow inside the else branch must emit from=bottom
96+
// (previous statement's From) and to=top (return's own To).
97+
if !hasFlow(oc.Flows, AnchorBottom, AnchorTop) {
98+
t.Errorf("expected log→return flow with Bottom→Top inside else branch, got %+v", oc.Flows)
99+
}
100+
}
101+
102+
func TestBuilder_AnchorToTopOnReturnPreservedInsideElse(t *testing.T) {
103+
// Minimal case: single-statement ELSE whose only statement is a RETURN
104+
// carrying @anchor(to: top). The flow from the split to that return's
105+
// EndEvent must land on DestinationConnectionIndex = AnchorTop.
106+
body := []ast.MicroflowStatement{
107+
&ast.IfStmt{
108+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
109+
ThenBody: []ast.MicroflowStatement{
110+
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "a"}},
111+
},
112+
ElseBody: []ast.MicroflowStatement{
113+
&ast.ReturnStmt{
114+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "no"},
115+
Annotations: &ast.ActivityAnnotations{
116+
Anchor: &ast.FlowAnchors{To: ast.AnchorSideTop, From: ast.AnchorSideUnset},
117+
},
118+
},
119+
},
120+
},
121+
}
122+
123+
oc := buildWithAnchors(body)
124+
125+
// Default downward flow from split has OriginConnectionIndex=Bottom; with
126+
// @anchor(to: top) on the return, DestinationConnectionIndex must be Top.
127+
if !hasFlow(oc.Flows, AnchorBottom, AnchorTop) {
128+
t.Errorf("expected split→return flow with Bottom→Top, got %+v", oc.Flows)
129+
}
130+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Regression tests for @annotation / @caption escaping in the microflow
4+
// describer. The free-annotation emission path used a home-grown escape that
5+
// only handled apostrophe doubling — newlines, tabs, and backslashes inside
6+
// the annotation text were emitted as raw control characters, which then
7+
// tripped the parser when the output was fed back through `mxcli exec`.
8+
//
9+
// The fix switches both the free-annotation and the ExclusiveSplit/
10+
// InheritanceSplit @caption emission to `mdlQuote`, which handles the full
11+
// set of MDL-sensitive characters and matches the single quoted-source helper
12+
// used everywhere else.
13+
package executor
14+
15+
import (
16+
"strings"
17+
"testing"
18+
)
19+
20+
func TestMdlQuote_EscapesNewlinesAndBackslashes(t *testing.T) {
21+
in := "SvdV (24/Mar/2021):\r\n\r\nThis microflow uses \\d in a regex."
22+
out := mdlQuote(in)
23+
24+
if strings.ContainsRune(out, '\n') {
25+
t.Errorf("mdlQuote output must not contain a raw newline, got %q", out)
26+
}
27+
if strings.ContainsRune(out, '\r') {
28+
t.Errorf("mdlQuote output must not contain a raw carriage return, got %q", out)
29+
}
30+
for _, want := range []string{`\r`, `\n`, `\\d`} {
31+
if !strings.Contains(out, want) {
32+
t.Errorf("mdlQuote output missing escaped sequence %q; got %q", want, out)
33+
}
34+
}
35+
if !strings.HasPrefix(out, "'") || !strings.HasSuffix(out, "'") {
36+
t.Errorf("mdlQuote output should be wrapped in single quotes: %q", out)
37+
}
38+
}
39+
40+
func TestMdlQuote_EscapesApostrophesByDoubling(t *testing.T) {
41+
in := "it's here"
42+
out := mdlQuote(in)
43+
if out != "'it''s here'" {
44+
t.Errorf("got %q, want %q", out, "'it''s here'")
45+
}
46+
}

mdl/executor/cmd_microflows_builder.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,21 @@ type flowBuilder struct {
3131
measurer *layoutMeasurer // For measuring statement dimensions
3232
nextConnectionPoint model.ID // For compound statements: the exit point differs from entry point
3333
nextFlowCase string // If set, next connecting flow uses this case value (for merge-less splits)
34+
// nextFlowAnchor carries the branch-specific FlowAnchors that should be
35+
// applied to the flow created by the NEXT iteration of buildFlowGraph.
36+
// Used by guard-pattern IFs (where one branch returns and the other
37+
// continues) so the continuing branch's @anchor survives to the actual
38+
// splitID→nextActivity flow — which is emitted one iteration later by the
39+
// outer loop, not by addIfStatement.
40+
nextFlowAnchor *ast.FlowAnchors
3441
backend backend.FullBackend // For looking up page/microflow references
3542
hierarchy *ContainerHierarchy // For resolving container IDs to module names
3643
pendingAnnotations *ast.ActivityAnnotations // Pending annotations to attach to next activity
3744
restServices []*model.ConsumedRestService // Cached REST services for parameter classification
45+
// previousStmtAnchor holds the Anchor annotation of the statement that
46+
// just emitted an activity, so the next flow's OriginConnectionIndex can
47+
// be overridden by the user. Cleared after each flow is created.
48+
previousStmtAnchor *ast.FlowAnchors
3849
}
3950

4051
// addError records a validation error during flow building.

0 commit comments

Comments
 (0)