Skip to content

Commit 5fb2719

Browse files
hjothamendixclaude
andcommitted
fixup: PR #276 review feedback (ako + codex)
Addresses the substantive and minor notes from both reviews on PR #276. ako review: - **[substantive] Remove package-level currentFlowsByOrigin/currentFlowsByDest globals**. These were a data race under captureDescribeParallel, which spawns concurrent describe goroutines. flowsByOrigin/flowsByDest are now explicit parameters on traverseFlow / traverseFlowUntilMerge / traverseLoopBody / emitLoopBody / emitActivityStatement / emitObjectAnnotations. The legacy Executor.traverseFlow wrapper passes nil for flowsByDest (preserves prior @anchor-suppressed behaviour for pre-existing callers and tests). Added TestFormatMicroflowActivities_ Concurrent_NoRace: 24 goroutines run formatMicroflowActivities in parallel on two distinct microflows and each worker's output is checked for correct per-flow @anchor content. Passes under -race. - **Duplicate assertion in TestBuilder_AnchorInsideElseBranch**. The second hasFlow(AnchorBottom, AnchorTop) duplicated the first and trivially passed once any match existed. Replaced with countFlows() to assert two distinct Bottom→Top flows (split→log and log→return). - **Dead code in TestBuilder_IfBranchAnchorOverrides**. The 12-line no-op type-assert block always fell through to a GetValue-based interface assertion that fails (EnumerationCase has no GetValue method). Replaced the whole post-build inspection with findBranchFlows — the describer's own helper that handles every CaseValue variant. - **LSP completion labels "Query keyword"** on TOP/BOTTOM/ANCHOR. Moved the anchor tokens out of the OQL/QUERY section into a new "ANCHOR ANNOTATION KEYWORDS" section in MDLLexer.g4 and taught gen-completions to map it to "Flow annotation keyword". - **quoteExpressionLiteral doc comment clarity**. Reworded the note about backslash followers that are not recognised escape letters: the backslash is emitted as-is and the follower is processed by the next loop iteration via the default arm — byte-identical to passthrough but walked separately. codex review: - **Parser/visitor regression tests for @anchor(iterator: ..., tail: ...)**. visitor_anchor_test.go gains three tests that feed real MDL text through Build() and assert IteratorAnchor / BodyTailAnchor fields: loop with both iterator+tail, while with both, loop with iterator-only (tail stays nil). The existing executor-side tests only used synthetic AST values. - **Clarify bug-test file wording**. The usage note said describe output "must include the @anchor(...) lines below verbatim" but the LOOP/WHILE section immediately below says those are parse-only. Restructured into two labeled sections: Section A = roundtrip-preserving (flat @anchor + IF split), Section B = parse-only forward-compatibility (LOOP/WHILE iterator/tail). Usage text now describes each section's guarantee explicitly. Validation: - go test ./... passes. - go test -race ./mdl/executor/ passes (including the new concurrent test). - go build -tags integration ./... builds clean. - Go lint passes. - On a fresh Mendix 11.9 project: mxcli exec of the updated bug-test script followed by mxcli docker check reports 0 errors. - describe → exec → describe is bit-exact for Section A microflows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b6afa4f commit 5fb2719

18 files changed

Lines changed: 453 additions & 222 deletions

cmd/gen-completions/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ func normalizeCategoryName(raw string) string {
304304
return "Connection keyword"
305305
case strings.Contains(raw, "OQL"), strings.Contains(raw, "QUERY"):
306306
return "Query keyword"
307+
case strings.Contains(raw, "ANCHOR"):
308+
return "Flow annotation keyword"
307309
case strings.Contains(raw, "MICROFLOW"):
308310
return "Microflow keyword"
309311
case strings.Contains(raw, "PAGE"), strings.Contains(raw, "WIDGET"):

cmd/mxcli/lsp_completions_gen.go

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mdl-examples/bug-tests/anchor-sequence-flow-annotation.mdl

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,24 @@
2121
--
2222
-- Usage:
2323
-- mxcli exec mdl-examples/bug-tests/anchor-sequence-flow-annotation.mdl -p app.mpr
24-
-- Then: mxcli describe microflow BugTestAnchor.MF_CustomAnchors -p app.mpr
25-
-- The describe output must include the @anchor(...) lines below verbatim.
24+
-- Then describe each microflow below with `mxcli describe microflow ...`.
25+
--
26+
-- The file has two sections with different roundtrip guarantees:
27+
--
28+
-- A) Roundtrip-preserving examples (flat @anchor + IF split form) — the
29+
-- describe output MUST include the @anchor(...) lines verbatim.
30+
-- Executing describe's output back into the project is bit-exact.
31+
--
32+
-- B) Parse-only forward-compatibility examples (LOOP / WHILE iterator
33+
-- and tail sides) — the grammar accepts @anchor(iterator: ..., tail: ...)
34+
-- but the builder intentionally does NOT emit those SequenceFlows today
35+
-- (see the note at the top of section B). Describe therefore will NOT
36+
-- echo iterator/tail back; the annotation is accepted so existing
37+
-- scripts keep parsing if Mendix ever starts recording those edges.
38+
-- ============================================================================
39+
40+
-- ============================================================================
41+
-- Section A — roundtrip-preserving examples
2642
-- ============================================================================
2743

2844
create module BugTestAnchor;
@@ -60,16 +76,18 @@ end;
6076
/
6177

6278
-- ============================================================================
63-
-- LOOP / WHILE body anchors
79+
-- Section B — parse-only forward-compatibility examples (LOOP / WHILE)
6480
-- ============================================================================
6581
-- The grammar accepts @anchor(iterator: (...), tail: (...)) on LOOP and
6682
-- WHILE statements so authoring tools can forward-propagate the intent, but
6783
-- the builder deliberately does NOT translate them into SequenceFlows.
6884
-- Mendix rejects edges between a LoopedActivity and its body statements with
6985
-- CE0709 "Sequence flow is not accepted by origin or destination" — the
7086
-- iterator icon is drawn implicitly from the LoopedActivity geometry.
71-
-- Keeping the grammar slot reserved means existing scripts continue to parse
72-
-- if a future Mendix version starts supporting these edges.
87+
--
88+
-- NOTE: describe will not echo `iterator: (...)` / `tail: (...)` in the
89+
-- output for the two microflows below. That is expected — see the usage
90+
-- text above.
7391

7492
create entity BugTestAnchor.Item (
7593
Label: String(100)

mdl/executor/cmd_microflows_anchor_if_test.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ func hasFlow(flows []anchorFlow, origin, dest int) bool {
5353
return false
5454
}
5555

56+
// countFlows counts how many flows have the given anchor pair.
57+
func countFlows(flows []anchorFlow, origin, dest int) int {
58+
n := 0
59+
for _, f := range flows {
60+
if f.OriginIdx == origin && f.DestIdx == dest {
61+
n++
62+
}
63+
}
64+
return n
65+
}
66+
5667
func TestBuilder_AnchorInsideElseBranch(t *testing.T) {
5768
// Reproduces the pattern from attempt #35:
5869
// if cond then { set ... }
@@ -88,14 +99,13 @@ func TestBuilder_AnchorInsideElseBranch(t *testing.T) {
8899

89100
oc := buildWithAnchors(body)
90101

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)
102+
// Two distinct Bottom→Top flows must exist:
103+
// 1. split → log (from the user's @anchor on the log statement)
104+
// 2. log → return (propagating the log's From=Bottom and the return's To=Top)
105+
// A single hasFlow check would pass with just one match, so count explicitly
106+
// to pin the regression — see ako review note on TestBuilder_AnchorInsideElseBranch.
107+
if got := countFlows(oc.Flows, AnchorBottom, AnchorTop); got != 2 {
108+
t.Errorf("expected 2 Bottom→Top flows (split→log and log→return), got %d: %+v", got, oc.Flows)
99109
}
100110
}
101111

mdl/executor/cmd_microflows_builder_anchor_test.go

Lines changed: 17 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -134,57 +134,22 @@ func TestBuilder_IfBranchAnchorOverrides(t *testing.T) {
134134
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
135135
oc := fb.buildFlowGraph(body, nil)
136136

137-
// Scan for the split→activity "true" and "false" flows and check their
138-
// OriginConnectionIndex matches the per-branch anchors.
139-
var trueFlow, falseFlow bool
140-
for _, f := range oc.Flows {
141-
if ec, ok := f.CaseValue.(ast.Expression); ok {
142-
_ = ec
143-
}
144-
switch cv := f.CaseValue.(type) {
145-
case nil:
146-
// skip
147-
default:
148-
_ = cv
149-
}
150-
// The split's outgoing flows carry an EnumerationCase with "true" / "false".
151-
if cv, ok := f.CaseValue.(interface{ GetValue() string }); ok {
152-
switch cv.GetValue() {
153-
case "true":
154-
if f.OriginConnectionIndex == AnchorTop && f.DestinationConnectionIndex == AnchorLeft {
155-
trueFlow = true
156-
}
157-
case "false":
158-
if f.OriginConnectionIndex == AnchorBottom && f.DestinationConnectionIndex == AnchorTop {
159-
falseFlow = true
160-
}
161-
}
162-
}
163-
}
164-
165-
// Fallback if the CaseValue interface doesn't expose GetValue — just check
166-
// that AT LEAST one flow with Top origin and one with Bottom origin exist.
167-
if !trueFlow {
168-
for _, f := range oc.Flows {
169-
if f.OriginConnectionIndex == AnchorTop && f.DestinationConnectionIndex == AnchorLeft {
170-
trueFlow = true
171-
break
172-
}
173-
}
174-
}
175-
if !falseFlow {
176-
for _, f := range oc.Flows {
177-
if f.OriginConnectionIndex == AnchorBottom && f.DestinationConnectionIndex == AnchorTop {
178-
falseFlow = true
179-
break
180-
}
181-
}
182-
}
183-
184-
if !trueFlow {
185-
t.Error("expected a split outgoing flow with origin=Top, destination=Left (true branch anchor)")
186-
}
187-
if !falseFlow {
188-
t.Error("expected a split outgoing flow with origin=Bottom, destination=Top (false branch anchor)")
137+
// Identify the TRUE/FALSE split outgoing flows via the describer's own
138+
// helper so we match every CaseValue variant the builder can emit
139+
// (ExpressionCase, EnumerationCase — value and pointer forms, BooleanCase).
140+
trueF, falseF := findBranchFlows(oc.Flows)
141+
if trueF == nil {
142+
t.Fatalf("expected a TRUE split flow, got none among %+v", oc.Flows)
143+
}
144+
if falseF == nil {
145+
t.Fatalf("expected a FALSE split flow, got none among %+v", oc.Flows)
146+
}
147+
if trueF.OriginConnectionIndex != AnchorTop || trueF.DestinationConnectionIndex != AnchorLeft {
148+
t.Errorf("true branch anchors: got from=%d to=%d, want Top(%d)/Left(%d)",
149+
trueF.OriginConnectionIndex, trueF.DestinationConnectionIndex, AnchorTop, AnchorLeft)
150+
}
151+
if falseF.OriginConnectionIndex != AnchorBottom || falseF.DestinationConnectionIndex != AnchorTop {
152+
t.Errorf("false branch anchors: got from=%d to=%d, want Bottom(%d)/Top(%d)",
153+
falseF.OriginConnectionIndex, falseF.DestinationConnectionIndex, AnchorBottom, AnchorTop)
189154
}
190155
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Tests that concurrent describe calls do not race on the describer's flow-map
4+
// state. The describer used to install package-level currentFlowsByOrigin /
5+
// currentFlowsByDest maps for the duration of a describe; two goroutines
6+
// running formatMicroflowActivities in parallel would clobber each other's
7+
// maps mid-traversal.
8+
//
9+
// This test drives formatMicroflowActivities from many goroutines at once.
10+
// It is primarily useful under `go test -race`, where the previous global-
11+
// state implementation reported a data race on currentFlowsByOrigin /
12+
// currentFlowsByDest. After threading the maps as explicit parameters the
13+
// race disappears; the test also asserts per-goroutine output integrity.
14+
package executor
15+
16+
import (
17+
"strings"
18+
"sync"
19+
"testing"
20+
21+
"github.com/mendixlabs/mxcli/model"
22+
"github.com/mendixlabs/mxcli/sdk/microflows"
23+
)
24+
25+
func TestFormatMicroflowActivities_Concurrent_NoRace(t *testing.T) {
26+
// Build two distinct microflows whose activities are anchored to
27+
// different sides. If the two describe calls share state, one will
28+
// emit the other's anchor keyword.
29+
mfA := mkRaceMicroflow("mfa-start", "mfa-log", "mfa-end", AnchorRight)
30+
mfB := mkRaceMicroflow("mfb-start", "mfb-log", "mfb-end", AnchorBottom)
31+
32+
e := newTestExecutor()
33+
ctx := e.newExecContext(t.Context())
34+
35+
const workersPerFlow = 12
36+
37+
var wg sync.WaitGroup
38+
resultsA := make([]string, workersPerFlow)
39+
resultsB := make([]string, workersPerFlow)
40+
for i := 0; i < workersPerFlow; i++ {
41+
wg.Add(2)
42+
go func(i int) {
43+
defer wg.Done()
44+
resultsA[i] = strings.Join(formatMicroflowActivities(ctx, mfA, nil, nil), "\n")
45+
}(i)
46+
go func(i int) {
47+
defer wg.Done()
48+
resultsB[i] = strings.Join(formatMicroflowActivities(ctx, mfB, nil, nil), "\n")
49+
}(i)
50+
}
51+
wg.Wait()
52+
53+
wantA := "@anchor(from: right, to: left)"
54+
wantB := "@anchor(from: bottom, to: left)"
55+
for i, got := range resultsA {
56+
if !strings.Contains(got, wantA) {
57+
t.Errorf("worker %d (A) missing %q in output:\n%s", i, wantA, got)
58+
}
59+
if strings.Contains(got, wantB) {
60+
t.Errorf("worker %d (A) unexpectedly contains %q from flow B:\n%s", i, wantB, got)
61+
}
62+
}
63+
for i, got := range resultsB {
64+
if !strings.Contains(got, wantB) {
65+
t.Errorf("worker %d (B) missing %q in output:\n%s", i, wantB, got)
66+
}
67+
if strings.Contains(got, wantA) {
68+
t.Errorf("worker %d (B) unexpectedly contains %q from flow A:\n%s", i, wantA, got)
69+
}
70+
}
71+
}
72+
73+
// mkRaceMicroflow builds a minimal microflow (Start → Log → End) whose
74+
// sequence-flow origin index matches the given anchorSide, so concurrent
75+
// describe outputs are distinguishable by their @anchor line.
76+
func mkRaceMicroflow(startID, logID, endID string, originSide int) *microflows.Microflow {
77+
start := &microflows.StartEvent{BaseMicroflowObject: mkObj(startID)}
78+
log := &microflows.ActionActivity{
79+
BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj(logID)},
80+
Action: &microflows.LogMessageAction{
81+
LogLevel: "Info",
82+
LogNodeName: "'App'",
83+
MessageTemplate: &model.Text{
84+
Translations: map[string]string{"en_US": "hi"},
85+
},
86+
},
87+
}
88+
end := &microflows.EndEvent{BaseMicroflowObject: mkObj(endID)}
89+
90+
// Flow start→log carries the distinguishing originSide on the LOG side
91+
// (where @anchor(from: ...) is emitted).
92+
flowSL := &microflows.SequenceFlow{
93+
OriginID: mkID(startID),
94+
DestinationID: mkID(logID),
95+
OriginConnectionIndex: AnchorRight,
96+
DestinationConnectionIndex: AnchorLeft,
97+
}
98+
flowLE := &microflows.SequenceFlow{
99+
OriginID: mkID(logID),
100+
DestinationID: mkID(endID),
101+
OriginConnectionIndex: originSide,
102+
DestinationConnectionIndex: AnchorLeft,
103+
}
104+
105+
return &microflows.Microflow{
106+
Name: "MF_" + logID,
107+
ObjectCollection: &microflows.MicroflowObjectCollection{
108+
Objects: []microflows.MicroflowObject{start, log, end},
109+
Flows: []*microflows.SequenceFlow{flowSL, flowLE},
110+
},
111+
}
112+
}

mdl/executor/cmd_microflows_helpers.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,12 @@ func mendixFunctionName(name string) string {
176176
// - Backslashes followed by one of the recognised escape letters (n/r/t/\/')
177177
// are doubled so the visitor's unquoteString preserves them — without this,
178178
// the source literal `\\n` would come back as a real newline on reparse.
179-
// - Any other backslash-prefixed sequence (e.g. `\d`, `\w`, `\p{...}` inside
180-
// regexes) is passed through unchanged so describe→exec roundtrips of
181-
// Mendix regular-expression arguments stay bit-exact.
179+
// - For any other backslash-prefixed byte (e.g. `\d`, `\w`, `\p{...}` inside
180+
// regexes) the backslash is emitted as-is and the follower is written by the
181+
// next loop iteration via the default arm, so the two bytes end up in the
182+
// output unchanged. This keeps Mendix regular-expression escape sequences
183+
// bit-exact across describe→exec roundtrips; the output is byte-identical
184+
// to passthrough even though the implementation walks the bytes separately.
182185
//
183186
// This is narrower than mdlQuote (used for @annotation / @caption text where
184187
// the AST value is a plain string): mdlQuote unconditionally doubles every

mdl/executor/cmd_microflows_show.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -681,11 +681,10 @@ func formatMicroflowActivities(
681681
// Build annotation map for @annotation emission
682682
annotationsByTarget := buildAnnotationsByTarget(mf.ObjectCollection)
683683

684-
// Install flow maps for @anchor emission during traversal.
685-
restore := setDescriberFlowMaps(flowsByOrigin, flowsByDest)
686-
defer restore()
687-
688-
traverseFlow(ctx, startID, activityMap, flowsByOrigin, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, nil, 0, annotationsByTarget)
684+
// flowsByOrigin / flowsByDest are threaded into traverseFlow so @anchor
685+
// emission is per-call — no package-level globals, safe under concurrent
686+
// describe (e.g. captureDescribeParallel).
687+
traverseFlow(ctx, startID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, nil, 0, annotationsByTarget)
689688

690689
return lines
691690
}
@@ -741,11 +740,7 @@ func formatMicroflowActivitiesWithSourceMap(
741740
// Build annotation map for @annotation emission
742741
annotationsByTarget := buildAnnotationsByTarget(mf.ObjectCollection)
743742

744-
// Install flow maps for @anchor emission during traversal.
745-
restore := setDescriberFlowMaps(flowsByOrigin, flowsByDest)
746-
defer restore()
747-
748-
traverseFlow(ctx, startID, activityMap, flowsByOrigin, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, sourceMap, headerLineCount, annotationsByTarget)
743+
traverseFlow(ctx, startID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, sourceMap, headerLineCount, annotationsByTarget)
749744

750745
return lines
751746
}

0 commit comments

Comments
 (0)