From b5409e494df36fef23c94975e019308ba3b0f39c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:06:54 +0000 Subject: [PATCH 01/22] test(backend): pin revoke create from several single-role rules (mendixlabs/mxcli#1499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `revoke create on entity M.E from M.RoleA, M.RoleB, M.RoleC` printed "Unchanged entity access: … — nothing to revoke", exit 0, and every rule kept create. Same cause as #1501: the partial-revoke matcher only touched a rule whose role set equalled the statement's, and create/delete go through that matcher too. fb0cec9c already fixed it on main; its tests pinned only the member-write right. Adds a backend test in the reported shape (one rule per role, two with XPath, one without), the bug-test script, and the finding. Control: restoring the sameStringSet guard fails the test with 0 rules modified and create kept on all three; the pre-fix binary prints the reported notice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PYLYzy5hqkdw3nKZm6uuS --- ...reate-several-roles-same-matcher-1499.json | 1 + .../1499-revoke-create-from-several-roles.mdl | 50 +++++++++++++ .../modelsdk/revoke_create_1499_test.go | 70 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-revoke-create-several-roles-same-matcher-1499.json create mode 100644 mdl-examples/bug-tests/1499-revoke-create-from-several-roles.mdl create mode 100644 mdl/backend/modelsdk/revoke_create_1499_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-revoke-create-several-roles-same-matcher-1499.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-revoke-create-several-roles-same-matcher-1499.json new file mode 100644 index 000000000..f6beadd04 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-revoke-create-several-roles-same-matcher-1499.json @@ -0,0 +1 @@ +{"area":"mdl/backend","date":"2026-10-17","symptom":"`revoke create on entity M.E from M.RoleA, M.RoleB, M.RoleC` printed \"Unchanged entity access: M.E (M.RoleA, M.RoleB, M.RoleC) — nothing to revoke\", exited 0, and `show access` still listed `Rights: create, read` for all three single-role rules (two with XPath, one without); check and mxbuild clean (mendixlabs/mxcli#1499).","cause":"Same as #1501: RevokeEntityMemberAccess only narrowed a rule whose role set EQUALLED the statement's, and every partial revoke — entity-level create/delete as well as member read/write — goes through that one matcher. Already fixed on main by fb0cec9c (#1501); #1499 was reported against v0.25.0, before it.","file":"mdl/backend/modelsdk/domainmodel_security_write.go (RevokeEntityMemberAccess target selection); test mdl/backend/modelsdk/revoke_create_1499_test.go","insight":"The reporter filed #1499 (create) and #1501 (write member) as separate bugs because the rights differ, and the dedupe against #1225 was right — but the variable shared by both was the multi-role statement over one-role-per-rule grants, not the right. Before fixing a revoke report, check which matcher the statement reaches: every partial revoke funnels into one target selection, so a fix there closes every right at once while its tests pin only the right the first report named. Pin the other rights with their own test; the control is restoring the `sameStringSet` equality guard, which reproduces 0 modified and create kept on all three.","refs":["mdl/backend/modelsdk/revoke_create_1499_test.go","mdl-examples/bug-tests/1499-revoke-create-from-several-roles.mdl","mendixlabs/mxcli#1501","mendixlabs/mxcli#1225"]} diff --git a/mdl-examples/bug-tests/1499-revoke-create-from-several-roles.mdl b/mdl-examples/bug-tests/1499-revoke-create-from-several-roles.mdl new file mode 100644 index 000000000..927dec089 --- /dev/null +++ b/mdl-examples/bug-tests/1499-revoke-create-from-several-roles.mdl @@ -0,0 +1,50 @@ +mdl 1; +-- ============================================================================ +-- Bug #1499 — `revoke create on entity … from RoleA, RoleB, RoleC` said +-- "nothing to revoke", exited 0, and `show access` still listed +-- `Rights: create, read` for all three roles. +-- +-- Same cause as #1501, a different right: the rules were granted one role +-- each, and a partial revoke only matched a rule whose role set EQUALLED the +-- statement's. RoleC's rule has no XPath, so the constraint is not the cause. +-- The #1501 fix (match every rule all of whose roles are named) covers the +-- entity-level create right too; this script pins it. +-- +-- mxcli exec this-file -p app.mpr +-- before: "Unchanged entity access: Issue1499.Ticket (Issue1499.RoleA, +-- Issue1499.RoleB, Issue1499.RoleC) — nothing to revoke"; +-- `list access` shows create, read for all three roles +-- after: "Revoked partial access on Issue1499.Ticket from …"; +-- read only, XPath kept on RoleA and RoleB +-- ============================================================================ + +create module Issue1499; +create module role Issue1499.RoleA; +create module role Issue1499.RoleB; +create module role Issue1499.RoleC; +create module role Issue1499.RoleD; + +@position(100, 100) +create persistent entity Issue1499.Ticket ( + Title: string(200) +); + +create association Issue1499.Ticket_Owner + from Issue1499.Ticket + to System.User + type Reference; + +-- One rule per role, `create, read`; two constrained, one not — the reported shape. +grant create, read * on entity Issue1499.Ticket to Issue1499.RoleA + where [Issue1499.Ticket_Owner = '[%CurrentUser%]']; +grant create, read * on entity Issue1499.Ticket to Issue1499.RoleB + where [Issue1499.Ticket_Owner = '[%CurrentUser%]']; +grant create, read * on entity Issue1499.Ticket to Issue1499.RoleC; +grant create, read * on entity Issue1499.Ticket to Issue1499.RoleD; + +-- One statement for all three roles: the bug. +revoke create on entity Issue1499.Ticket from Issue1499.RoleA, Issue1499.RoleB, Issue1499.RoleC; +-- The reporter's control, which always worked. +revoke all on entity Issue1499.Ticket from Issue1499.RoleD; + +list access on entity Issue1499.Ticket; diff --git a/mdl/backend/modelsdk/revoke_create_1499_test.go b/mdl/backend/modelsdk/revoke_create_1499_test.go new file mode 100644 index 000000000..e6a5b03d5 --- /dev/null +++ b/mdl/backend/modelsdk/revoke_create_1499_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1499: `revoke create on entity M.E from M.RoleA, M.RoleB, +// M.RoleC` answered "Unchanged entity access: … — nothing to revoke", exit 0, +// while `show access` still listed `Rights: create, read` for all three roles. +// +// Same matcher as #1501, a different right: the rules were granted one role +// each, and the partial revoke only touched a rule whose role set EQUALLED the +// statement's. #1501's tests cover a member right only; the entity-level create +// right is pinned here, in the reported shape (two rules constrained, one not — +// the reporter's control that the XPath is not the cause). +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +func TestRevokeCreate_NarrowsEachSingleRoleRuleTheStatementNames(t *testing.T) { + b, mod, dm := grantFixture(t) + roles := []string{"MyFirstModule.RoleA", "MyFirstModule.RoleB", "MyFirstModule.RoleC"} + xpaths := map[string]string{ + "MyFirstModule.RoleA": ownerXPath, + "MyFirstModule.RoleB": ownerXPath, + "MyFirstModule.RoleC": "", + } + for _, r := range roles { + // grant create, read * on … to r [where …] + p := grantParams(dm.ID, "ReadOnly", xpaths[r], nil) + p.RoleNames = []string{r} + p.AllowCreate = true + if err := b.AddEntityAccessRule(p); err != nil { + t.Fatalf("AddEntityAccessRule %s: %v", r, err) + } + } + + n, err := b.RevokeEntityMemberAccess(dm.ID, "ZzCust", roles, types.EntityAccessRevocation{RevokeCreate: true}) + if err != nil { + t.Fatalf("revoke: %v", err) + } + if n != 3 { + t.Errorf("rules modified = %d, want 3 — 0 is the reported \"nothing to revoke\"", n) + } + for _, rule := range rulesOf(t, b, mod.ID) { + if len(rule.ModuleRoleNames) != 1 { + t.Fatalf("rule roles = %v, want one role each", rule.ModuleRoleNames) + } + r := rule.ModuleRoleNames[0] + if rule.AllowCreate { + t.Errorf("%s: create still granted", r) + } + if rule.XPathConstraint != xpaths[r] { + t.Errorf("%s: XPath = %q, want %q", r, rule.XPathConstraint, xpaths[r]) + } + if string(rule.DefaultMemberAccessRights) != "ReadOnly" { + t.Errorf("%s: default member access = %q, want ReadOnly (read stays)", r, rule.DefaultMemberAccessRights) + } + for m, got := range rightsOf(rule) { + if got != "ReadOnly" { + t.Errorf("%s: %s = %q, want ReadOnly (read stays)", r, m, got) + } + } + } + + // A rerun is the honest idempotent case: nothing left to revoke. + if n, err := b.RevokeEntityMemberAccess(dm.ID, "ZzCust", roles, types.EntityAccessRevocation{RevokeCreate: true}); err != nil || n != 0 { + t.Errorf("rerun: modified = %d, err = %v; want 0, nil", n, err) + } +} From a0faea02396c1dd95ba1b02bc28780be5e9d0eb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:31:32 +0000 Subject: [PATCH 02/22] fix: ALTER SET writes a pluggable Boolean as "true"/"false", not "yes"/"no" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `alter page … set (showFooter: true, clearable: false) on cbColor` stored PrimitiveValue "yes" / "no" (mendixlabs/mxcli#1454). Studio Pro and the widget template store a Boolean as "true" / "false", and the client reads anything but "true" as false, so a bare `true` passed mx check and turned the property off. setPluggableWidgetPropertyMut now spells a bool from the property's declared kind: Boolean -> "true"/"false"; an Enumeration that declares yes/no keys keeps taking a boolean as those keys; any other enumeration refuses a boolean and names its keys. Control: with the fix stashed, TestSetPluggableBooleanWritesTrueFalse_1454 fails with `PrimitiveValue = "yes", want "true"`. On a blank 11.13.0 app the unfixed and fixed builds differ in exactly the two reported values, and both report 0 errors from mx check, which is why only the stored bytes show the defect. Refs ako/mxcli#1327 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- ...0-17-pluggable-boolean-written-yes-no.json | 1 + ...alter-set-pluggable-boolean-true-false.mdl | 37 ++++++++ mdl/backend/pagemutator/mutator.go | 61 ++++++++++++- mdl/backend/pagemutator/mutator_test.go | 8 +- .../pluggable_boolean_1454_test.go | 86 +++++++++++++++++++ 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-pluggable-boolean-written-yes-no.json create mode 100644 mdl-examples/bug-tests/1454-alter-set-pluggable-boolean-true-false.mdl create mode 100644 mdl/backend/pagemutator/pluggable_boolean_1454_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-pluggable-boolean-written-yes-no.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-pluggable-boolean-written-yes-no.json new file mode 100644 index 000000000..d384aabb2 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-pluggable-boolean-written-yes-no.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-17", "symptom": "`alter page … set (showFooter: true, clearable: false) on ` stores PrimitiveValue \"yes\" / \"no\"; mx check passes and the client reads \"yes\" as false, so a bare `true` turns the property off", "cause": "setPluggableWidgetPropertyMut spelled every Go bool as yes/no regardless of the property's declared ValueType.Type, although Studio Pro and the widget template store a Boolean as true/false", "file": "`mdl/backend/pagemutator/mutator.go` (`setPluggableWidgetPropertyMut`, new `pluggableBooleanSpelling`)", "insight": "The literal's type is not the stored type: the spelling must come from the schema (propKindMap) the same way columnValueField already picks the value field. yes/no is real only as the keys of an Enumeration that declares them (the reporter's 17.5k-unit scan: 0 yes/no among 13k Boolean values, 2.1k among enumerations), so those keep taking a boolean and any other enumeration refuses one. A unit test named TestSetWidgetProperty_PluggableWidget had pinned \"no\" on a kindless fixture — the bug's spelling was the asserted contract. mx check accepts both spellings (0 errors on fixed and unfixed builds), so only the stored bytes show it", "refs": ["mendixlabs/mxcli#1454", "ako/mxcli#1327"]} diff --git a/mdl-examples/bug-tests/1454-alter-set-pluggable-boolean-true-false.mdl b/mdl-examples/bug-tests/1454-alter-set-pluggable-boolean-true-false.mdl new file mode 100644 index 000000000..044e42d8f --- /dev/null +++ b/mdl-examples/bug-tests/1454-alter-set-pluggable-boolean-true-false.mdl @@ -0,0 +1,37 @@ +mdl 1; +-- mendixlabs/mxcli#1454: `alter … set (showFooter: true, clearable: false) on +-- ` stored the pluggable properties' PrimitiveValue as "yes" / "no". +-- Studio Pro and the widget template store a Boolean as "true" / "false", and +-- the compiled client reads anything but "true" as false — so a bare `true` +-- passed mx check and switched the property OFF. +-- +-- The spelling now comes from the property's declared kind: a Boolean writes +-- "true" / "false"; an Enumeration that declares yes/no keys keeps taking a +-- boolean as those keys; any other enumeration refuses a boolean. +-- +-- Verify after exec: +-- mxcli bson dump -p app.mpr --type page --object Bug1454.Item_Edit --format ndsl \ +-- | grep -A3 -E 'PropertyKey: "(showFooter|clearable)"' +-- and the combo box's stored values are "true" / "false", never "yes" / "no". + +create module Bug1454; + +create enumeration Bug1454.Color (Red 'Red', Blue 'Blue'); + +create persistent entity Bug1454.Item (Color: enumeration(Bug1454.Color)); + +create page Bug1454.Item_Edit ( + title: 'Item', + layout: Atlas_Core.Atlas_Default, + params: ( $Item: Bug1454.Item ) +) { + dataview dvItem (DataSource: $Item) { + combobox cbColor (Label: 'Color', Attribute: Color) + } +}; + +alter page Bug1454.Item_Edit { + set (showFooter: true, clearable: false) on cbColor +}; + +describe page Bug1454.Item_Edit; diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 26a95a2a9..1639bd92b 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "sort" + "strconv" "strings" "go.mongodb.org/mongo-driver/bson" @@ -3811,11 +3812,11 @@ func setPluggableWidgetPropertyMut(widget bson.D, propName string, value any) er case string: bsonnav.DSet(valDoc, "PrimitiveValue", v) case bool: - if v { - bsonnav.DSet(valDoc, "PrimitiveValue", "yes") - } else { - bsonnav.DSet(valDoc, "PrimitiveValue", "no") + spelled, err := pluggableBooleanSpelling(widget, typePointerID, kind, propName, v) + if err != nil { + return err } + bsonnav.DSet(valDoc, "PrimitiveValue", spelled) case int: bsonnav.DSet(valDoc, "PrimitiveValue", fmt.Sprintf("%d", v)) case float64: @@ -3828,6 +3829,58 @@ func setPluggableWidgetPropertyMut(widget bson.D, propName string, value any) er return fmt.Errorf("pluggable property %q not found", propName) } +// pluggableBooleanSpelling is how a bare `true`/`false` is stored in a pluggable +// property, which the property's declared kind decides — not the MDL literal. +// It was always "yes"/"no" (mendixlabs/mxcli#1454), the spelling of neither +// Studio Pro nor the widget template for a Boolean: those store "true"/"false", +// and the client reads anything but "true" as false, so `set (showFooter: true)` +// passed mx check and switched the footer off. A production scan of 13k Boolean +// values found no "yes"/"no" at all; those strings occur only as the keys of an +// Enumeration that literally declares them, which keeps taking a boolean. Any +// other enumeration has no key a boolean can mean, so it is refused rather +// than written with a key the widget does not declare. +func pluggableBooleanSpelling(widget bson.D, typePointerID, kind, propName string, v bool) (string, error) { + if kind != "Enumeration" { + return strconv.FormatBool(v), nil + } + keys := pluggableEnumerationKeys(widget, typePointerID) + want := "no" + if v { + want = "yes" + } + for _, k := range keys { + if k == want { + return k, nil + } + } + return "", fmt.Errorf("pluggable property %q is an enumeration (%s), not a Boolean — "+ + "write one of its keys as a quoted string instead of %v", + propName, strings.Join(keys, ", "), v) +} + +// pluggableEnumerationKeys lists the keys the widget's schema declares for the +// enumeration property typePointerID, in declaration order. +func pluggableEnumerationKeys(widget bson.D, typePointerID string) []string { + objType := bsonnav.DGetDoc(bsonnav.DGetDoc(widget, "Type"), "ObjectType") + for _, pt := range bsonnav.DGetArrayElements(bsonnav.DGet(objType, "PropertyTypes")) { + ptDoc, ok := pt.(bson.D) + if !ok || bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(ptDoc, "$ID")) != typePointerID { + continue + } + var keys []string + valueType := bsonnav.DGetDoc(ptDoc, "ValueType") + for _, ev := range bsonnav.DGetArrayElements(bsonnav.DGet(valueType, "EnumerationValues")) { + if evDoc, ok := ev.(bson.D); ok { + if k := bsonnav.DGetString(evDoc, "_Key"); k != "" { + keys = append(keys, k) + } + } + } + return keys + } + return nil +} + // noPluggableObjectError explains a SET that reached the pluggable fallback on a // widget that has no pluggable Object — i.e. a built-in one, whose vocabulary is // setRawWidgetPropertyMut's switch and nothing else. diff --git a/mdl/backend/pagemutator/mutator_test.go b/mdl/backend/pagemutator/mutator_test.go index 9e96bc786..23f0f5647 100644 --- a/mdl/backend/pagemutator/mutator_test.go +++ b/mdl/backend/pagemutator/mutator_test.go @@ -481,7 +481,7 @@ func TestSetWidgetProperty_PluggableWidget(t *testing.T) { bson.D{ {Key: "TypePointer", Value: propTypeID}, {Key: "Value", Value: bson.D{ - {Key: "PrimitiveValue", Value: "yes"}, + {Key: "PrimitiveValue", Value: "true"}, }}, }, }}, @@ -502,8 +502,10 @@ func TestSetWidgetProperty_PluggableWidget(t *testing.T) { props := bsonnav.DGetArrayElements(bsonnav.DGet(obj, "Properties")) propDoc := props[0].(bson.D) valDoc := bsonnav.DGetDoc(propDoc, "Value") - if bsonnav.DGetString(valDoc, "PrimitiveValue") != "no" { - t.Errorf("Expected PrimitiveValue='no', got %v", bsonnav.DGet(valDoc, "PrimitiveValue")) + // "false", not "no": this assertion used to pin the yes/no spelling that + // mendixlabs/mxcli#1454 showed the client reads as false either way. + if bsonnav.DGetString(valDoc, "PrimitiveValue") != "false" { + t.Errorf("Expected PrimitiveValue='false', got %v", bsonnav.DGet(valDoc, "PrimitiveValue")) } } diff --git a/mdl/backend/pagemutator/pluggable_boolean_1454_test.go b/mdl/backend/pagemutator/pluggable_boolean_1454_test.go new file mode 100644 index 000000000..d42bc4ac5 --- /dev/null +++ b/mdl/backend/pagemutator/pluggable_boolean_1454_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// mendixlabs/mxcli#1454: `set (showFooter: true, clearable: false) on cbColor` +// stored PrimitiveValue "yes" / "no". Studio Pro and the widget template store a +// Boolean property as "true" / "false", and the compiled client reads anything +// but "true" as false — so a bare `true` silently did the opposite. +func TestSetPluggableBooleanWritesTrueFalse_1454(t *testing.T) { + for _, tc := range []struct { + value any + want string + }{{true, "true"}, {false, "false"}} { + w := kindedPluggableWidget("showFooter", "Boolean", "", false) + if err := setPluggableWidgetPropertyMut(w, "showFooter", tc.value); err != nil { + t.Fatalf("set %v: %v", tc.value, err) + } + if got := kindedValue(t, w).Map()["PrimitiveValue"]; got != tc.want { + t.Errorf("set showFooter = %v: PrimitiveValue = %q, want %q", tc.value, got, tc.want) + } + } +} + +// withEnumKeys gives the kinded widget's single PropertyType enumeration keys. +func withEnumKeys(w bson.D, keys ...string) bson.D { + vals := bson.A{int32(2)} + for _, k := range keys { + vals = append(vals, bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetEnumerationValue"}, + {Key: "Caption", Value: k}, + {Key: "_Key", Value: k}, + }) + } + pts := kindedPropertyTypes(w) + pt := pts[0].(bson.D) + for i := range pt { + if pt[i].Key == "ValueType" { + pt[i].Value = append(pt[i].Value.(bson.D), bson.E{Key: "EnumerationValues", Value: vals}) + } + } + return w +} + +// An Enumeration whose keys are literally yes/no (1,298 "yes" values in the +// reporter's production scan) keeps taking a bare boolean as those keys. +func TestSetPluggableEnumYesNoTakesBoolean_1454(t *testing.T) { + w := withEnumKeys(kindedPluggableWidget("mode", "Enumeration", "", false), "yes", "no") + if err := setPluggableWidgetPropertyMut(w, "mode", true); err != nil { + t.Fatal(err) + } + if got := kindedValue(t, w).Map()["PrimitiveValue"]; got != "yes" { + t.Errorf("PrimitiveValue = %q, want yes", got) + } +} + +// Any other enumeration has no key a boolean can mean, so it is refused rather +// than written as a key the widget does not declare. +func TestSetPluggableEnumRefusesBoolean_1454(t *testing.T) { + w := withEnumKeys(kindedPluggableWidget("source", "Enumeration", "", false), "context", "database") + err := setPluggableWidgetPropertyMut(w, "source", true) + if err == nil { + t.Fatalf("expected a refusal, wrote %q", kindedValue(t, w).Map()["PrimitiveValue"]) + } + if !strings.Contains(err.Error(), "context") || !strings.Contains(err.Error(), "database") { + t.Errorf("refusal should name the declared keys: %v", err) + } +} + +func kindedPropertyTypes(w bson.D) []any { + objType := bsonnav.DGetDoc(bsonnav.DGetDoc(w, "Type"), "ObjectType") + for _, e := range objType { + if e.Key == "PropertyTypes" { + return e.Value.(bson.A)[1:] + } + } + return nil +} From 54f4dad60c447ffd0fdc109133fd68e0030d4fcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:31:45 +0000 Subject: [PATCH 03/22] fix: user tasks always written with auto-assign off; add `auto assign single user` Every single- and multi-user task was written with AutoAssignSingleTargetUser = false (a constant in userTaskToGen and the MCP constructor), the reader never read it, describe never printed it, and MDL had no spelling, so `create or modify workflow` turned off a box ticked in Studio Pro. The runtime then did not assign a task whose targeting yielded one user, and completing it from a microflow failed with "it is not assigned to you". check and mx check were silent. - `auto assign single user` on user task and multi user task, at most once - written from the model, read back on all three task types, described when set - create or modify / replace activity that does not restate a stored `true` is refused, like `await all users` (omission must be able to mean off, so a carry would make it impossible to turn off) - default stays off: Model SDK default false, and Studio Pro 11.14.0 (ako/TestApp) stores false on every new user task Fixes #1445 (mendixlabs/mxcli#1547) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MqgMzUGeMdUt9ZBdNkoUr --- ...ser-task-auto-assign-hard-coded-false.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 13 +- CHANGELOG.md | 4 + cmd/mxcli/lsp_completions_gen.go | 2 + cmd/mxcli/syntax/features_workflow.go | 7 +- docs-site/src/language/workflow-activities.md | 2 + docs/01-project/MDL_QUICK_REFERENCE.md | 3 +- .../bug-tests/1547-user-task-auto-assign.mdl | 67 +++++++++ mdl/ast/ast_workflow.go | 1 + mdl/backend/mcp/workflow.go | 6 +- mdl/backend/mcp/workflow_completion_test.go | 23 +++ .../modelsdk/workflow_auto_assign_test.go | 88 ++++++++++++ mdl/backend/modelsdk/workflow_read.go | 3 + mdl/backend/modelsdk/workflow_write.go | 2 +- mdl/executor/cmd_workflows.go | 4 + mdl/executor/cmd_workflows_write.go | 1 + mdl/executor/validate_workflow_rewrite.go | 46 ++++++ mdl/executor/workflow_auto_assign_test.go | 136 ++++++++++++++++++ mdl/grammar/MDLLexer.g4 | 3 + mdl/grammar/domains/MDLSettings.g4 | 4 +- mdl/grammar/domains/MDLWorkflow.g4 | 1 + mdl/visitor/visitor_workflow.go | 2 + mdl/visitor/visitor_workflow_clauses.go | 2 + sdk/workflows/workflow.go | 4 + 24 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-user-task-auto-assign-hard-coded-false.json create mode 100644 mdl-examples/bug-tests/1547-user-task-auto-assign.mdl create mode 100644 mdl/backend/modelsdk/workflow_auto_assign_test.go create mode 100644 mdl/executor/workflow_auto_assign_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-user-task-auto-assign-hard-coded-false.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-user-task-auto-assign-hard-coded-false.json new file mode 100644 index 000000000..8e1048632 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-user-task-auto-assign-hard-coded-false.json @@ -0,0 +1 @@ +{"area":"mdl/backend","date":"2026-10-17","symptom":"Every user task written with AutoAssignSingleTargetUser = false; no MDL clause, not in describe, and create or modify workflow turned off a box ticked in Studio Pro — completing the task from a microflow then failed at runtime with 'it is not assigned to you'; check and mx check silent","cause":"userTaskToGen wrote addBool(g, \"AutoAssignSingleTargetUser\", false) as a constant (the MCP route sent the same literal); the reader never read the property, so the rewrite guard, which counts stored settings, had nothing to compare","file":"mdl/backend/modelsdk/workflow_write.go, mdl/backend/mcp/workflow.go, mdl/executor/validate_workflow_rewrite.go","insight":"This is the 'loss set is every constant the rebuild writes' audit from rewrite-drops-unauthored-state, missed for a property that sat one line above the guarded AwaitAllUsers: grep the writer for addBool/addStr with a literal and each hit is a candidate. Studio Pro's default for a NEW task is false (Model SDK default false; ako/TestApp 11.14.0 stores false on all three of its user tasks), so the default was right and only the reset was wrong — the fix is restate-or-refuse like await all users, not a changed default. A carry (keep stored value when omitted) was the reporter's ask but cannot work for a presence-only boolean clause: omission could then never turn it off. On mxcli-authored content the stored value IS the constant, so only a fixture with the flag set to true can show the bug.","refs":["#1445","mendixlabs/mxcli#1547"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 64ad722b0..59cfb221b 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -20,9 +20,9 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel | 409-429 | Rewriting an existing workflow | | 430-517 | Microflow statements for workflow tasks | | 518-538 | System-module enumerations are synthesized, not stored | -| 539-656 | Platform rules | -| 657-687 | Observing a running workflow | -| 688-697 | Validate before presenting | +| 539-663 | Platform rules | +| 664-694 | Observing a running workflow | +| 695-704 | Validate before presenting | # Mendix Workflows Skill @@ -565,6 +565,13 @@ values. The full list and the System **entities** are in `system-module`. An outcome left **empty** does not stop anything — it rejoins the main flow. `caption '…'` sets the End's caption, as on every workflow activity (`comment '…'` is its deprecated alias, MDL-DEPR104). +- **`auto assign single user` assigns a task when its targeting yields exactly one user** + (Studio Pro's "Auto-assign when targeting results in one user"), on either task kind. + Without it that user cannot complete the task from a microflow (`set task outcome`) + until it is assigned — the runtime refuses with "it is not assigned to you". Omitted + it is off, as Studio Pro creates a task; `describe` prints it when set, and a + `create or modify` that drops a stored one is refused. + - **A multi-user task says who must respond and how their outcomes decide**: `participants all | | percent`, `decide by …` and `await all users`, in any order (see the clause-order note below). The rules (`decide by`): diff --git a/CHANGELOG.md b/CHANGELOG.md index 0798c1db7..8870c439b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **Workflow user tasks keep "Auto-assign when targeting results in one user"** (mendixlabs/mxcli#1547) — every single- and multi-user task was written with `AutoAssignSingleTargetUser = false`, MDL had no spelling for it and `describe workflow` did not print it, so `create or modify workflow` turned off a box ticked in Studio Pro. When the targeting then yielded one user, the task was not assigned to them and completing it from a microflow failed at runtime with "You can't complete this user task, it is not assigned to you"; `check` and `mx check` were silent. The new clause `auto assign single user` (on `user task` and `multi user task`) writes it, `describe` prints it when set, and a `create or modify` or `replace activity` that does not restate a stored one is refused, like `await all users`. Omitted, it is off, which is what Studio Pro 11.14 stores for a new task. The MCP backend sent `false` too and now sends the task's value. + ## [0.27.0] - 2026-10-15 Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories. diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index d74c3ffae..6825d046e 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -648,6 +648,8 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "FALLBACK", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "PARTICIPANTS", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "AWAIT", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, + {Label: "AUTO", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, + {Label: "ASSIGN", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "DATA", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "TRANSFORM", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, {Label: "TRANSFORMER", Kind: protocol.CompletionItemKindKeyword, Detail: "Keyword"}, diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 3b12bf94e..0d2e78e40 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -114,7 +114,7 @@ func init() { Path: "workflow.user-task", Summary: "User task activity — assigns work to users with outcomes", Keywords: []string{ - "user task", "human task", "assign", "assignee", + "user task", "human task", "assign", "assignee", "auto assign", "auto-assign", "outcomes", "approve", "reject", "page", }, // The task page's parameter rule is the platform's, not MDL's, and it was @@ -125,6 +125,7 @@ func init() { Syntax: "[MULTI] USER TASK ''\n" + " PAGE Module.TaskPage\n" + " [TARGETING [USERS | GROUPS] MICROFLOW Module.MF | TARGETING [USERS | GROUPS] XPATH []]\n" + + " [AUTO ASSIGN SINGLE USER] -- assign the task when targeting yields exactly one user\n" + " [ON CREATED MICROFLOW Module.MF] -- (System.WorkflowUserTask, ), returns nothing\n" + " [ENTITY Module.Entity]\n" + " [DUE DATE ]\n" + @@ -134,6 +135,9 @@ func init() { "-- stores one user source — so writing both is refused rather than\n" + "-- letting the second silently win. OUTCOMES and BOUNDARY EVENT are\n" + "-- list-valued and may repeat.\n\n" + + "-- AUTO ASSIGN SINGLE USER is Studio Pro's \"Auto-assign when targeting\n" + + "-- results in one user\". Omitted, it is off, as Studio Pro creates a task;\n" + + "-- CREATE OR MODIFY refuses to turn a stored one off unless restated.\n\n" + "-- The task page is opened with the TASK, not with the workflow's context\n" + "-- object, so it must take a System.WorkflowUserTask parameter:\n" + "-- page with no parameters -> CE7410\n" + @@ -195,6 +199,7 @@ func init() { Syntax: "MULTI USER TASK ''\n" + " PAGE Module.TaskPage\n" + " [TARGETING [USERS | GROUPS] MICROFLOW Module.MF | TARGETING [USERS | GROUPS] XPATH []]\n" + + " [AUTO ASSIGN SINGLE USER] -- assign the task when targeting yields exactly one user\n" + " [ON CREATED MICROFLOW Module.MF] -- (System.WorkflowUserTask, ), returns nothing\n" + " [ENTITY Module.Entity]\n" + " [DUE DATE ]\n" + diff --git a/docs-site/src/language/workflow-activities.md b/docs-site/src/language/workflow-activities.md index e538dbad6..72340f1c2 100644 --- a/docs-site/src/language/workflow-activities.md +++ b/docs-site/src/language/workflow-activities.md @@ -10,6 +10,7 @@ A user task pauses the workflow until a user completes it. Each outcome resumes USER TASK '' [PAGE .] [TARGETING MICROFLOW .] + [AUTO ASSIGN SINGLE USER] [ON CREATED MICROFLOW .] OUTCOMES '' { } ['' { }] ...; ``` @@ -20,6 +21,7 @@ USER TASK '' | `` | Display label shown to users | | `PAGE` | The page opened when the user acts on the task | | `TARGETING MICROFLOW` | Microflow that determines which users see the task | +| `AUTO ASSIGN SINGLE USER` | Studio Pro's "Auto-assign when targeting results in one user": when the targeting yields exactly one user, the task is assigned to them. Off when omitted, as Studio Pro creates a task. Without it, that user cannot complete the task from a microflow until it is assigned to them. Valid on multi-user tasks too | | `ON CREATED MICROFLOW` | Microflow run when the task is created — for example to assign it. It takes exactly `System.WorkflowUserTask` and the workflow's context entity, in either order (else CE6683), and returns nothing (else CE5012) | | `OUTCOMES` | Named outcomes, each with a block of follow-up activities | diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 6525f0c8b..4c3c86c36 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -745,8 +745,9 @@ mandatory and a misplaced clause failed with a token error (`mismatched input 'ON' expecting ';'`) that named neither the clause nor the rule. **Workflow Activity Types:** -- `[multi] user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath []] [on created microflow Mod.MF] [entity Mod.Entity] [due date ] [description ''] [participants all|| percent] [decide by ] [await all users] [outcomes '' { } ...] [boundary event …];` +- `[multi] user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath []] [auto assign single user] [on created microflow Mod.MF] [entity Mod.Entity] [due date ] [description ''] [participants all|| percent] [decide by ] [await all users] [outcomes '' { } ...] [boundary event …];` - **Multi-user only:** `decide by consensus|majority more than half|majority most chosen|threshold percent|votes fallback ''`, `decide by veto ''`, `decide by microflow Mod.MF`. A fallback is required for consensus, majority and threshold (CE1866), a veto needs its outcome (CE1867), and a decision microflow returns String (CE5012) — all `MDL-WF13` / check. Omitted: all participants, consensus on the first outcome, not waiting. + - `auto assign single user` is Studio Pro's "Auto-assign when targeting results in one user" (either task kind). Omitted it is off, as Studio Pro creates a task; `create or modify` refuses to drop a stored one that is not restated. - The **task page** must take a `System.WorkflowUserTask` parameter — none at all is CE7410, none of that type is CE7412; extra parameters are allowed. - A **targeting microflow** takes exactly `System.Workflow` + the context entity (or a generalization of it), in either order — anything else is CE6677. Users targeting returns a list of `System.User`, groups a list of `System.WorkflowGroup`. - An **on-created microflow** takes exactly `System.WorkflowUserTask` + the context entity, in either order (CE6683), and returns nothing (CE5012). diff --git a/mdl-examples/bug-tests/1547-user-task-auto-assign.mdl b/mdl-examples/bug-tests/1547-user-task-auto-assign.mdl new file mode 100644 index 000000000..4a15b9a1c --- /dev/null +++ b/mdl-examples/bug-tests/1547-user-task-auto-assign.mdl @@ -0,0 +1,67 @@ +mdl 1; +-- mendixlabs/mxcli#1547 (ako/mxcli#1445): "Auto-assign when targeting results +-- in one user" on a user task. +-- +-- Every user task was written with AutoAssignSingleTargetUser = false, MDL had +-- no spelling for it and describe did not print it, so `create or modify +-- workflow` turned off a box ticked in Studio Pro. When the targeting then +-- yielded one user, completing the task from a microflow failed at runtime +-- ("You can't complete this user task, it is not assigned to you"). +-- +-- `auto assign single user` writes it on both task kinds; `describe workflow` +-- prints it when set. Omitted, it is off — what Studio Pro 11.14 stores for a +-- new task. Expected: `bson dump` shows AutoAssignSingleTargetUser true on +-- ReviewOrder and Vote, false on Archive, and the script builds with 0 errors. + +create module AA; +create persistent entity AA.Order ( Number: string(20) ); + +create page AA.Order_Review ( + title: 'Review order', + layout: Atlas_Core.Atlas_Default, + params: ( $WorkflowUserTask: System.WorkflowUserTask ) +) { + dynamictext txtReview (content: 'Review') +}; + +create workflow AA.Order_Approval + parameter $WorkflowContext: AA.Order +begin + user task ReviewOrder 'Review the order' + page AA.Order_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + auto assign single user + outcomes 'Approve' { } 'Reject' { }; + multi user task Vote 'Vote on the order' + page AA.Order_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + auto assign single user + decide by consensus fallback 'Approve' + outcomes 'Approve' { } 'Reject' { }; + user task Archive 'Archive the order' + page AA.Order_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Done' { }; +end workflow; + +-- A rewrite that restates the clause keeps it; leaving it off ReviewOrder or +-- Vote here would be refused rather than silently turning auto-assign off. +create or modify workflow AA.Order_Approval + parameter $WorkflowContext: AA.Order +begin + user task ReviewOrder 'Review the order' + page AA.Order_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + auto assign single user + outcomes 'Approve' { } 'Reject' { }; + multi user task Vote 'Vote on the order' + page AA.Order_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + auto assign single user + decide by consensus fallback 'Approve' + outcomes 'Approve' { } 'Reject' { }; + user task Archive 'Archive the order' + page AA.Order_Review + targeting users xpath [System.UserRoles = '[%UserRole_User%]'] + outcomes 'Done' { }; +end workflow; diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index a5bb5d5b2..fdd67216b 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -109,6 +109,7 @@ type WorkflowUserTaskNode struct { IsMultiUser bool // Issue #8: true if MULTI USER TASK BoundaryEvents []WorkflowBoundaryEventNode // Issue #7 TaskDescription string // from DESCRIPTION 'text' + AutoAssign bool // `auto assign single user` (AutoAssignSingleTargetUser) // Multi-user task only. Participants *WorkflowParticipantsNode // `participants …`; nil = all diff --git a/mdl/backend/mcp/workflow.go b/mdl/backend/mcp/workflow.go index 15c76ad77..7b53bfeb7 100644 --- a/mdl/backend/mcp/workflow.go +++ b/mdl/backend/mcp/workflow.go @@ -1131,6 +1131,10 @@ func mapWorkflowActivity(a workflows.WorkflowActivity) (map[string]any, error) { "outcomes": vals, } mapMultiUserTaskCompletion(m, act, vals) + // Sent only when on, like awaitAllUsers: off is the constructor's default. + if act.AutoAssignSingleTargetUser { + m["autoAssignSingleTargetUser"] = true + } } else { outcomes, err := mapUserTaskOutcomes(act.Outcomes) if err != nil { @@ -1147,7 +1151,7 @@ func mapWorkflowActivity(a workflows.WorkflowActivity) (map[string]any, error) { "userTargeting": mapUserTargeting(act.UserSource), "onCreatedEvent": mapOnCreatedEvent(act.OnCreated), "outcomes": outcomes, - "autoAssignSingleTargetUser": false, + "autoAssignSingleTargetUser": act.AutoAssignSingleTargetUser, } } if bevents != nil { diff --git a/mdl/backend/mcp/workflow_completion_test.go b/mdl/backend/mcp/workflow_completion_test.go index d1d3f3288..fa02bc370 100644 --- a/mdl/backend/mcp/workflow_completion_test.go +++ b/mdl/backend/mcp/workflow_completion_test.go @@ -121,3 +121,26 @@ func TestApplyMoreThanHalfFallbacks(t *testing.T) { } } } + +// Issue #1445: the single-user constructor sent autoAssignSingleTargetUser = +// false whatever the task said. +func TestMapUserTaskAutoAssign(t *testing.T) { + for _, multi := range []bool{false, true} { + for _, on := range []bool{false, true} { + task := &workflows.UserTask{ + BaseWorkflowActivity: workflows.BaseWorkflowActivity{Name: "T", Caption: "c"}, + IsMulti: multi, + Outcomes: []*workflows.UserTaskOutcome{{Value: "A"}}, + AutoAssignSingleTargetUser: on, + } + m, err := mapWorkflowActivity(task) + if err != nil { + t.Fatal(err) + } + got, _ := m["autoAssignSingleTargetUser"].(bool) + if got != on { + t.Errorf("multi=%v: autoAssignSingleTargetUser = %v, want %v", multi, m["autoAssignSingleTargetUser"], on) + } + } + } +} diff --git a/mdl/backend/modelsdk/workflow_auto_assign_test.go b/mdl/backend/modelsdk/workflow_auto_assign_test.go new file mode 100644 index 000000000..d0ce26ead --- /dev/null +++ b/mdl/backend/modelsdk/workflow_auto_assign_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// Issue #1445: userTaskToGen wrote AutoAssignSingleTargetUser = false on every +// user task whatever the model said, and the reader never read it back. Both +// task kinds, both values, survive a write and a fresh read — checked on the +// stored key as well as through the reader, so the reader cannot mask the writer. +func TestCreateWorkflow_AutoAssignSingleTargetUserRoundTrip(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + + want := map[string]bool{"singleOn": true, "singleOff": false, "multiOn": true, "multiOff": false} + acts := []workflows.WorkflowActivity{&workflows.StartWorkflowActivity{BaseWorkflowActivity: workflows.BaseWorkflowActivity{Name: "Start"}}} + for _, name := range []string{"singleOn", "singleOff", "multiOn", "multiOff"} { + acts = append(acts, &workflows.UserTask{ + BaseWorkflowActivity: workflows.BaseWorkflowActivity{Name: name, Caption: name}, + IsMulti: name == "multiOn" || name == "multiOff", + Outcomes: []*workflows.UserTaskOutcome{{Value: "Approve"}}, + AutoAssignSingleTargetUser: want[name], + }) + } + acts = append(acts, &workflows.EndWorkflowActivity{BaseWorkflowActivity: workflows.BaseWorkflowActivity{Name: "End"}}) + wf := &workflows.Workflow{ContainerID: mod.ID, Name: "ZzAutoAssign", Parameter: &workflows.WorkflowParameter{EntityRef: "MyFirstModule.Ctx"}, Flow: &workflows.Flow{Activities: acts}} + if err := b.CreateWorkflow(wf); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + + b2 := New() + if err := b2.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b2.Disconnect() }) + all, err := b2.ListWorkflows() + if err != nil { + t.Fatal(err) + } + var got *workflows.Workflow + for _, w := range all { + if w.Name == "ZzAutoAssign" { + got = w + } + } + if got == nil { + t.Fatal("workflow not found after reconnect") + } + + raw, err := b2.GetRawUnit(got.ID) + if err != nil { + t.Fatal(err) + } + stored := map[string]any{} + for _, a := range raw["Flow"].(map[string]any)["Activities"].([]any) { + if m, ok := a.(map[string]any); ok { + if n, _ := m["Name"].(string); n != "" { + stored[n] = m["AutoAssignSingleTargetUser"] + } + } + } + read := map[string]bool{} + for _, a := range got.Flow.Activities { + if ut, ok := a.(*workflows.UserTask); ok { + read[ut.Name] = ut.AutoAssignSingleTargetUser + } + } + for name, w := range want { + if stored[name] != w { + t.Errorf("%s: stored AutoAssignSingleTargetUser = %v, want %v", name, stored[name], w) + } + if read[name] != w { + t.Errorf("%s: read back AutoAssignSingleTargetUser = %v, want %v", name, read[name], w) + } + } +} diff --git a/mdl/backend/modelsdk/workflow_read.go b/mdl/backend/modelsdk/workflow_read.go index 651ec8749..778ee4164 100644 --- a/mdl/backend/modelsdk/workflow_read.go +++ b/mdl/backend/modelsdk/workflow_read.go @@ -182,6 +182,7 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { OnCreated: microflowEventName(a.OnCreatedEvent()), } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$UserTask") + t.AutoAssignSingleTargetUser = a.AutoAssignSingleTargetUser() if t.Page == "" { t.Page = taskPageName(a.TaskPage()) } @@ -197,6 +198,7 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { Page: taskPageName(a.TaskPage()), } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$SingleUserTaskActivity") + t.AutoAssignSingleTargetUser = a.AutoAssignSingleTargetUser() t.BoundaryEvents = boundaryEventsFromGen(a.BoundaryEventsItems()) t.Outcomes = userTaskOutcomesFromGen(a.OutcomesItems()) return t @@ -211,6 +213,7 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { Page: taskPageName(a.TaskPage()), } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$MultiUserTaskActivity") + t.AutoAssignSingleTargetUser = a.AutoAssignSingleTargetUser() t.BoundaryEvents = boundaryEventsFromGen(a.BoundaryEventsItems()) t.Outcomes = userTaskOutcomesFromGen(a.OutcomesItems()) t.AwaitAllUsers = a.AwaitAllUsers() diff --git a/mdl/backend/modelsdk/workflow_write.go b/mdl/backend/modelsdk/workflow_write.go index d22f23e40..508aa68a2 100644 --- a/mdl/backend/modelsdk/workflow_write.go +++ b/mdl/backend/modelsdk/workflow_write.go @@ -410,7 +410,7 @@ func userTaskToGen(a *workflows.UserTask) element.Element { if a.Annotation != "" { addPart(g, "Annotation", annotationElem(a.Annotation)) } - addBool(g, "AutoAssignSingleTargetUser", false) + addBool(g, "AutoAssignSingleTargetUser", a.AutoAssignSingleTargetUser) if a.IsMulti { addBool(g, "AwaitAllUsers", a.AwaitAllUsers) } diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index 6fe941c00..6ba9f35af 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -528,6 +528,10 @@ func formatUserTask(ctx *ExecContext, a *workflows.UserTask, indent string) []st } } } + // Off is what a task gets without the clause, so only on is printed. + if a.AutoAssignSingleTargetUser { + lines = append(lines, indent+" auto assign single user") + } if a.OnCreated != "" { lines = append(lines, fmt.Sprintf("%s on created microflow %s", indent, a.OnCreated)) diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index f763d9f4d..bf59a5d71 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -503,6 +503,7 @@ func buildUserTask(n *ast.WorkflowUserTaskNode) *workflows.UserTask { task.DueDate = n.DueDate task.TaskDescription = n.TaskDescription task.IsMulti = n.IsMultiUser + task.AutoAssignSingleTargetUser = n.AutoAssign if n.IsMultiUser { task.AwaitAllUsers = n.AwaitAllUsers task.TargetUserInput = buildTargetUserInput(n.Participants) diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index 808adb96d..5f75eb909 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -144,6 +144,18 @@ func checkNoDroppedWorkflowConstructs(ctx *ExecContext, workflowID model.ID, qua } } + // "Auto-assign when targeting results in one user" on either task kind. Every + // rebuild wrote it off until `auto assign single user` existed (#1445), and an + // omitted clause still means off, so a stored `true` must be restated. + if stored, authored := rawAutoAssignTasks(raw), countAuthoredAutoAssign(authoredActs); len(stored) > authored { + return mdlerrors.NewUnsupported(fmt.Sprintf( + "workflow %s has %d user task(s) that auto-assign when targeting results in one user but this statement "+ + "declares %d — rewriting it would turn auto-assign off for the difference:\n - %s\n"+ + " Restate them (`auto assign single user`), which `describe workflow %s` now emits, or use ALTER "+ + "WORKFLOW to change one activity at a time.", + qualifiedName, len(stored), authored, strings.Join(stored, "\n - "), qualifiedName)) + } + // Ends inside branches. MDL could not state one until `end workflow`, and // describe dropped them, so a rewrite from an older describe output would // delete every one — and a branch that ended the workflow would silently @@ -392,9 +404,43 @@ func rawReplaceLosses(d map[string]any, replacement ast.WorkflowActivityNode) [] if rawAwaitsAllUsers(d) && (!isMulti || !mut.AwaitAllUsers) { out = append(out, "it waits for all users, which the replacement does not restate (add `await all users`)") } + if rawAutoAssigns(d) && (mut == nil || !mut.AutoAssign) { + out = append(out, "it auto-assigns when targeting results in one user, which the replacement does not restate (add `auto assign single user`)") + } + return out +} + +func rawAutoAssigns(d map[string]any) bool { + on, _ := d["AutoAssignSingleTargetUser"].(bool) + return on +} + +// rawAutoAssignTasks labels every stored user task, single or multi, that +// auto-assigns when its targeting yields one user. +func rawAutoAssignTasks(raw map[string]any) []string { + var out []string + walkRawDocs(raw, func(d map[string]any) { + switch t, _ := d["$Type"].(string); t { + case "Workflows$SingleUserTaskActivity", "Workflows$MultiUserTaskActivity", "Workflows$UserTask": + if rawAutoAssigns(d) { + name, _ := d["Name"].(string) + out = append(out, fmt.Sprintf("user task '%s' auto-assigns a single targeted user", name)) + } + } + }) return out } +func countAuthoredAutoAssign(activities []ast.WorkflowActivityNode) int { + n := 0 + walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) { + if t, ok := a.(*ast.WorkflowUserTaskNode); ok && t.AutoAssign { + n++ + } + }) + return n +} + // validateAlterReplaceKeepsStudioProState refuses REPLACE ACTIVITY on an activity // whose stored document holds state the replacement is rebuilt without. Measured // on ako/TestApp: replacing a user task with an identical one reset its diff --git a/mdl/executor/workflow_auto_assign_test.go b/mdl/executor/workflow_auto_assign_test.go new file mode 100644 index 000000000..6b96d7019 --- /dev/null +++ b/mdl/executor/workflow_auto_assign_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// Issue #1445: every user task was written with AutoAssignSingleTargetUser = +// false, MDL had no spelling for it and describe did not print it. The clause +// `auto assign single user` now states it on both task kinds. +func TestWorkflowAutoAssign_BuildsBothTaskKinds(t *testing.T) { + stmt := parseWorkflowStmt(t, `create workflow M.W parameter $C: M.Ctx begin + user task singleTask 'c' page M.P targeting users xpath [System.UserRoles = '[%UserRole_User%]'] auto assign single user outcomes 'A' { }; + multi user task multiTask 'c' page M.P auto assign single user outcomes 'A' { }; + user task plainTask 'c' page M.P outcomes 'A' { }; +end workflow;`) + got := map[string]bool{} + for _, a := range stmt.Activities { + task := buildUserTask(a.(*ast.WorkflowUserTaskNode)) + got[task.Name] = task.AutoAssignSingleTargetUser + } + if !got["singleTask"] || !got["multiTask"] { + t.Errorf("auto assign not carried: %v", got) + } + if got["plainTask"] { + t.Error("a task without the clause must not auto-assign (Studio Pro's default is off)") + } +} + +// describe prints the clause when set and only then, and a re-parse of the +// output rebuilds the same value — so a round trip keeps it. +func TestWorkflowAutoAssign_DescribeRoundTrips(t *testing.T) { + for _, multi := range []bool{false, true} { + for _, on := range []bool{false, true} { + task := &workflows.UserTask{ + BaseWorkflowActivity: workflows.BaseWorkflowActivity{Name: "T", Caption: "c"}, + IsMulti: multi, + Page: "M.P", + Outcomes: []*workflows.UserTaskOutcome{{Value: "A", Caption: "A"}}, + AutoAssignSingleTargetUser: on, + } + out := strings.Join(formatSingleActivity(task, " "), "\n") + if strings.Contains(out, "auto assign single user") != on { + t.Errorf("multi=%v on=%v: describe output\n%s", multi, on, out) + } + stmt := parseWorkflowStmt(t, "create workflow M.W begin\n"+out+"\nend workflow;") + again := buildUserTask(stmt.Activities[0].(*ast.WorkflowUserTaskNode)) + if again.AutoAssignSingleTargetUser != on { + t.Errorf("multi=%v on=%v did not round-trip:\n%s", multi, on, out) + } + } + } +} + +func parseClauseErrors(src string) []error { + _, errs := visitor.Build(src) + return errs +} + +func TestWorkflowAutoAssign_AtMostOnce(t *testing.T) { + errs := parseClauseErrors(`create workflow M.W begin user task T 'c' auto assign single user page M.P auto assign single user outcomes 'A' { }; end workflow;`) + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "duplicate AUTO ASSIGN SINGLE USER clause") { + t.Errorf("want a duplicate-clause error, got %v", errs) + } +} + +// storedAutoAssign is the Studio Pro workflow fixture with the auto-assign box +// ticked on both its single (userTask1) and multi (userTask2) user task. +func storedAutoAssign() map[string]any { + raw := studioProWorkflow(false, false, false, "ConsensusCompletionCriteria", true) + for _, a := range raw["Flow"].(map[string]any)["Activities"].([]any) { + if m, ok := a.(map[string]any); ok && (m["Name"] == "userTask1" || m["Name"] == "userTask2") { + m["AutoAssignSingleTargetUser"] = true + } + } + return raw +} + +// The reported reset: `create or modify workflow` rebuilt every task with +// AutoAssignSingleTargetUser = false. A rewrite that does not restate a stored +// `true` is refused, naming the task; one that restates it goes through. +func TestWorkflowRewrite_AutoAssignMustBeRestated(t *testing.T) { + stmt := func(single, multi string) *ast.CreateWorkflowStmt { + return parseWorkflowStmt(t, `create or modify workflow M.W parameter $C: M.Ctx +begin + user task userTask1 'User Task' page M.P `+single+` outcomes 'Good' { } 'Bad' { }; + multi user task userTask2 'Multi' page M.P `+multi+` outcomes 'Fast' { } 'Furious' { }; +end workflow;`) + } + cases := []struct{ name, single, multi, want string }{ + {"nothing restated", ``, ``, "auto-assign"}, + {"single only", `auto assign single user`, ``, "userTask2"}, + {"multi only", ``, `auto assign single user`, "userTask1"}, + {"both restated", `auto assign single user`, `auto assign single user`, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, storedAutoAssign()), "wf1", "M.W", stmt(c.single, c.multi)) + switch { + case c.want == "" && err != nil: + t.Errorf("a rewrite restating auto-assign must be allowed, got %v", err) + case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)): + t.Errorf("must be refused naming %q, got %v", c.want, err) + } + }) + } + // Control: the fixture without the flag set is rewritten freely. + if err := checkNoDroppedWorkflowConstructs(rawWorkflowCtx(t, studioProWorkflow(false, false, false, "ConsensusCompletionCriteria", true)), "wf1", "M.W", stmt(``, ``)); err != nil { + t.Errorf("a workflow without auto-assign must not be refused: %v", err) + } +} + +// REPLACE ACTIVITY rebuilds the task from the statement too. +func TestAlterWorkflow_ReplaceRestatesAutoAssign(t *testing.T) { + replace := func(clauses string) *ast.AlterWorkflowStmt { + prog, errs := visitor.Build(`alter workflow M.W replace activity userTask1 with user task userTask1 'User Task' page M.P ` + clauses + ` outcomes 'Good' { } 'Bad' { };`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + return prog.Statements[0].(*ast.AlterWorkflowStmt) + } + ctx := storedReplaceCtx(t, storedAutoAssign()) + if got := validateAlterReplaceKeepsStudioProState(ctx, replace(`auto assign single user`)); len(got) != 0 { + t.Errorf("a replacement restating auto-assign must be allowed, got %v", got) + } + got := validateAlterReplaceKeepsStudioProState(ctx, replace(``)) + if len(got) != 1 || !strings.Contains(got[0], "auto assign single user") { + t.Errorf("want one refusal naming the clause, got %v", got) + } +} diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 259f52864..c81464239 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -915,6 +915,9 @@ VETO: V E T O; FALLBACK: F A L L B A C K; PARTICIPANTS: P A R T I C I P A N T S; AWAIT: A W A I T; +// `auto assign single user` on a user task (AutoAssignSingleTargetUser). +AUTO: A U T O; +ASSIGN: A S S I G N; // Data transformer tokens DATA: D A T A; diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 3d1286d54..9e78c481c 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -658,9 +658,9 @@ keyword // Agent editor | AGENT | AGENTS | KNOWLEDGE | BASES | CONSUMED | MCP | TOOL - // Workflow multi-user task completion — words, so still usable as names + // Workflow user task settings — words, so still usable as names | DECIDE | CONSENSUS | MAJORITY | MORE_KW | THAN | HALF | MOST | CHOSEN | THRESHOLD - | PERCENT_KW | VOTES | VETO | FALLBACK | PARTICIPANTS | AWAIT + | PERCENT_KW | VOTES | VETO | FALLBACK | PARTICIPANTS | AWAIT | AUTO | ASSIGN // Microflow / Nanoflow | MICROFLOW | MICROFLOWS | NANOFLOW | NANOFLOWS diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index 11ab0b6bf..ebb2a074e 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -216,6 +216,7 @@ workflowUserTaskClause | DESCRIPTION STRING_LITERAL | OUTCOMES workflowUserTaskOutcome+ | BOUNDARY EVENT workflowBoundaryEventClause ((BOUNDARY EVENT)? workflowBoundaryEventClause)* + | AUTO ASSIGN SINGLE USER // "Auto-assign when targeting results in one user" ; /** The above, plus the three clauses only a multi user task has. */ diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index 041c3d18c..bba45581c 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -704,6 +704,8 @@ func applyWorkflowUserTaskClause(node *ast.WorkflowUserTaskNode, clause parser.I for _, beCtx := range c.AllWorkflowBoundaryEventClause() { node.BoundaryEvents = append(node.BoundaryEvents, buildBoundaryEventNode(beCtx)) } + case c.AUTO() != nil: + node.AutoAssign = true } } diff --git a/mdl/visitor/visitor_workflow_clauses.go b/mdl/visitor/visitor_workflow_clauses.go index 541de86dd..f5b251aa7 100644 --- a/mdl/visitor/visitor_workflow_clauses.go +++ b/mdl/visitor/visitor_workflow_clauses.go @@ -122,6 +122,8 @@ func workflowUserTaskClauseKind(c *parser.WorkflowUserTaskClauseContext) string return "DESCRIPTION" case c.OUTCOMES() != nil: return "OUTCOMES" + case c.AUTO() != nil: + return "AUTO ASSIGN SINGLE USER" } // Boundary events accumulate. return "" diff --git a/sdk/workflows/workflow.go b/sdk/workflows/workflow.go index baaa8a52b..53a75f7e7 100644 --- a/sdk/workflows/workflow.go +++ b/sdk/workflows/workflow.go @@ -157,6 +157,10 @@ type UserTask struct { OnCreated string `json:"onCreated,omitempty"` // Microflow called on task creation BoundaryEvents []*BoundaryEvent `json:"boundaryEvents,omitempty"` // Boundary events (e.g., timers) + // AutoAssignSingleTargetUser is Studio Pro's "Auto-assign when targeting + // results in one user". Off by default, as Studio Pro creates a task. + AutoAssignSingleTargetUser bool `json:"autoAssignSingleTargetUser,omitempty"` + // Multi-user task only. CompletionCriteria *CompletionCriteria `json:"completionCriteria,omitempty"` // nil = consensus on the first outcome TargetUserInput *TargetUserInput `json:"targetUserInput,omitempty"` // nil = all targeted users From 3edb2b32d9d4db9631827c406360f5e86f94d84f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:53:45 +0000 Subject: [PATCH 04/22] refactor: move the pluggable slot-keyword table into mdl/types widgetSlotKeywordOverrides / mdlContainerForWidgetSlot decide that a Data grid 2's filtersPlaceholder is spelled `controlbar` and a Gallery's `filter`. The page mutator is about to resolve the same keyword for `.` targets, so the table moves to mdl/types beside ItemPropertyAliases, for the same reason: two paths reading one table cannot drift. No behaviour change; the executor names are kept as thin aliases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- mdl/executor/widget_defs.go | 39 ++++----------------------- mdl/types/widget_slot_keywords.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 34 deletions(-) create mode 100644 mdl/types/widget_slot_keywords.go diff --git a/mdl/executor/widget_defs.go b/mdl/executor/widget_defs.go index bf2131078..7626ecbe7 100644 --- a/mdl/executor/widget_defs.go +++ b/mdl/executor/widget_defs.go @@ -460,43 +460,14 @@ var widgetVisibilityRules = map[string][]types.WidgetVisibilityRule{ }, } -// widgetSlotKeywordOverrides maps (widgetID, propertyKey) pairs to the MDL -// keyword used in a widget body to fill that property. Most widgets[]-typed -// properties use the uppercase property key as their MDL keyword; entries -// here cover the cases where the keyword is a different conventional word. -// -// Background: a widget's .mpk declares property keys (e.g. filtersPlaceholder) -// but not the MDL keyword users type for that slot. Studio Pro authors think -// of `controlbar { ... }` (DataGrid) and `filter { ... }` (Gallery) rather -// than `filtersplaceholder { ... }`. The keyword paths (datagrid_builder.go -// etc.) encode this convention today; this table makes the same mapping -// visible to the registry-driven engine. -// -// Keys are (widgetID, propertyKey). When v0.12.0 collapses the keyword paths -// into the engine, this table is the single source of truth for the convention. -var widgetSlotKeywordOverrides = map[string]map[string]string{ - "com.mendix.widget.web.datagrid.Datagrid": { - "filtersPlaceholder": "CONTROLBAR", - }, - "com.mendix.widget.web.gallery.Gallery": { - "filtersPlaceholder": "FILTER", - }, -} +// widgetSlotKeywordOverrides is the shared slot-keyword table in mdl/types, +// where the page mutator reads it too (`.` addresses a slot). +var widgetSlotKeywordOverrides = types.WidgetSlotKeywordOverrides // mdlContainerForWidgetSlot returns the MDL keyword for a widgets-typed -// property. Defaults to the uppercase property key; recognized special cases -// override that default. Widget-specific entries in widgetSlotKeywordOverrides -// win over the global `content` → `TEMPLATE` convention. +// property — see types.WidgetSlotKeyword. func mdlContainerForWidgetSlot(widgetID, propertyKey string) string { - if widgetSpecific, ok := widgetSlotKeywordOverrides[widgetID]; ok { - if kw, ok := widgetSpecific[propertyKey]; ok { - return kw - } - } - if propertyKey == "content" { - return "TEMPLATE" - } - return strings.ToUpper(propertyKey) + return types.WidgetSlotKeyword(widgetID, propertyKey) } // itemSlotAcceptedChildTypes lists widget MDL keywords that route to a diff --git a/mdl/types/widget_slot_keywords.go b/mdl/types/widget_slot_keywords.go new file mode 100644 index 000000000..642950382 --- /dev/null +++ b/mdl/types/widget_slot_keywords.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "strings" + +// WidgetSlotKeywordOverrides maps (widgetID, propertyKey) pairs to the MDL +// keyword used in a widget body to fill that property. Most widgets[]-typed +// properties use the uppercase property key as their MDL keyword; entries +// here cover the cases where the keyword is a different conventional word. +// +// Background: a widget's .mpk declares property keys (e.g. filtersPlaceholder) +// but not the MDL keyword users type for that slot. Studio Pro authors think +// of `controlbar { ... }` (DataGrid) and `filter { ... }` (Gallery) rather +// than `filtersplaceholder { ... }`. +// +// It lives here, like ItemPropertyAliases, because two code paths must agree on +// it: the create path and describe (mdl/executor), which spell a slot with this +// keyword, and the page mutator, which resolves the same keyword when ALTER +// PAGE addresses the slot as `.`. +var WidgetSlotKeywordOverrides = map[string]map[string]string{ + DataGridWidgetID: { + "filtersPlaceholder": "CONTROLBAR", + }, + "com.mendix.widget.web.gallery.Gallery": { + "filtersPlaceholder": "FILTER", + }, +} + +// WidgetSlotKeyword returns the MDL keyword for a widgets-typed property. +// Defaults to the uppercase property key; widget-specific entries in +// WidgetSlotKeywordOverrides win over the global `content` → `TEMPLATE` +// convention. +func WidgetSlotKeyword(widgetID, propertyKey string) string { + if widgetSpecific, ok := WidgetSlotKeywordOverrides[widgetID]; ok { + if kw, ok := widgetSpecific[propertyKey]; ok { + return kw + } + } + if propertyKey == "content" { + return "TEMPLATE" + } + return strings.ToUpper(propertyKey) +} From 3b7ddae2bb8739ed7088826384c6e55c7c5ccf28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:55:23 +0000 Subject: [PATCH 05/22] fix: ALTER PAGE SET DataSource writes a Gallery's / Data grid 2's source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `alter page … set (DataSource: microflow Shop.DS_Order_Open) on galOrders` passed check, printed "Altered page" and left the page unit byte-identical (mendixlabs/mxcli#1548); the same statement on a list view worked. A CustomWidget keeps its source in the datasource property's Value, not in a top-level DataSource key, and SetWidgetDataSource's DSet onto that missing key failed with its result ignored. For a pluggable widget the source is now written into that property, via the engine's pluggable serializer (the one CREATE and INSERT use). Microflow and nanoflow sources are written; other kinds are refused with a pointer to REPLACE, and check reports the refusal because its dry run runs the setter. The plain-widget path now checks DSet's result, so no widget can report success without a slot to write into. Control: with the fix stashed, the four new tests fail (source unchanged, no error). On a blank 11.13.0 app the unfixed build leaves both widgets on `database from V.Item`; the fixed build shows `microflow V.DS_Items` for both, and mx check reports 0 errors either way. Refs ako/mxcli#1446 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- ...-set-datasource-pluggable-silent-noop.json | 1 + ...48-alter-set-datasource-pluggable-list.mdl | 44 +++++++ mdl/backend/pagemutator/mutator.go | 53 +++++++- .../pagemutator/set_datasource_1548_test.go | 115 ++++++++++++++++++ 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-set-datasource-pluggable-silent-noop.json create mode 100644 mdl-examples/bug-tests/1548-alter-set-datasource-pluggable-list.mdl create mode 100644 mdl/backend/pagemutator/set_datasource_1548_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-set-datasource-pluggable-silent-noop.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-set-datasource-pluggable-silent-noop.json new file mode 100644 index 000000000..6ae04b7b5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-set-datasource-pluggable-silent-noop.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-17", "symptom": "`alter page … set (DataSource: microflow M.F) on ` passes check, prints \"Altered page\", and the page unit is byte-identical; the same statement on a list view works", "cause": "SetWidgetDataSource ended with bsonnav.DSet(widget, \"DataSource\", …) and ignored its result; a CustomWidgets$CustomWidget has no top-level DataSource key (the source is Object.Properties[datasource].Value.DataSource), so DSet replaced nothing", "file": "`mdl/backend/pagemutator/mutator.go` (`SetWidgetDataSource`, new `setPluggableDataSource`, `pluggableDataSourceValue`)", "insight": "bsonnav.DSet replaces an existing key and returns false otherwise — an ignored DSet result is a silent-success generator, and grep for bare `bsonnav.DSet(` statements is a quick audit for the class. The READ side (widgetOwnDataSourceDoc, #1076) had already learned both locations; the write side had not, so the fix reuses that walk. The pluggable source is serialized with deps.SerializeCustomWidgetDataSource, the serializer CREATE/INSERT use for the same property, not serializeDataSourceBson; only microflow/nanoflow are written, and other kinds are refused with a pointer to REPLACE. check's dry run (probe.go) runs the real setter, so that refusal reaches check with no extra code", "refs": ["mendixlabs/mxcli#1548", "ako/mxcli#1446"]} diff --git a/mdl-examples/bug-tests/1548-alter-set-datasource-pluggable-list.mdl b/mdl-examples/bug-tests/1548-alter-set-datasource-pluggable-list.mdl new file mode 100644 index 000000000..0a8e57f2e --- /dev/null +++ b/mdl-examples/bug-tests/1548-alter-set-datasource-pluggable-list.mdl @@ -0,0 +1,44 @@ +mdl 1; +-- mendixlabs/mxcli#1548: `alter page … set (DataSource: microflow M.F) on ` +-- worked on a list view, but on a Gallery or Data grid 2 it passed check, +-- printed "Altered page" and left the page unit byte-identical. A pluggable +-- widget keeps its source in its `datasource` property, not in a top-level +-- DataSource key, and the setter's write onto that missing key failed silently. +-- +-- Expected: the describe below shows all three widgets with +-- `DataSource: microflow Bug1548.DS_Order_Open`. A source kind `set` cannot +-- write onto a pluggable widget (e.g. `selection `) is refused by check. + +create module Bug1548; + +create persistent entity Bug1548.Order (Number: string(20)); + +create microflow Bug1548.DS_Order_Open () +returns list of Bug1548.Order as $Orders +begin + retrieve $Orders from Bug1548.Order; + return $Orders; +end; + +create page Bug1548.Order_Overview ( + title: 'Orders', + layout: Atlas_Core.Atlas_Default +) { + gallery galOrders (DataSource: database Bug1548.Order) { + dynamictext txtNumber (Content: '{1}', ContentParams: ({1} = Number)) + } + datagrid dgOrders (DataSource: database Bug1548.Order) { + column (Attribute: Number) + } + listview lvOrders (DataSource: database Bug1548.Order) { + dynamictext txtNumber2 (Content: '{1}', ContentParams: ({1} = Number)) + } +}; + +alter page Bug1548.Order_Overview { + set (DataSource: microflow Bug1548.DS_Order_Open) on galOrders; + set (DataSource: microflow Bug1548.DS_Order_Open) on dgOrders; + set (DataSource: microflow Bug1548.DS_Order_Open) on lvOrders +}; + +describe page Bug1548.Order_Overview; diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 26a95a2a9..4dec0c815 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -150,11 +150,52 @@ func (m *Mutator) SetWidgetDataSource(widgetRef string, ds pages.DataSource) err return err } + if bsonnav.DGetString(result.widget, "$Type") == "CustomWidgets$CustomWidget" { + return m.setPluggableDataSource(result.widget, ds) + } + serialized := serializeDataSourceBson(ds) if serialized == nil { return fmt.Errorf("unsupported DataSource type %T", ds) } - bsonnav.DSet(result.widget, "DataSource", serialized) + // DSet only replaces an existing key. Ignoring its result is how a widget + // with nowhere to keep a source reported "Altered page" unchanged + // (mendixlabs/mxcli#1548). + if !bsonnav.DSet(result.widget, "DataSource", serialized) { + return fmt.Errorf("%q (%s) has no data source to set", widgetRef, widgetTypeName(result.widget)) + } + return nil +} + +// setPluggableDataSource writes ds into a pluggable widget's datasource +// property. A Gallery or Data grid 2 has no top-level DataSource key — the +// source is the Value of the property its schema keys "datasource" — so the +// plain-widget write above found nothing to replace and reported success with +// the unit byte-identical (mendixlabs/mxcli#1548). The source is serialized by +// the engine's pluggable serializer, the one CREATE PAGE and INSERT use for +// the same property, rather than by serializeDataSourceBson: a second copy of +// that shape is the drift this file keeps paying for. +func (m *Mutator) setPluggableDataSource(widget bson.D, ds pages.DataSource) error { + name := bsonnav.DGetString(widget, "Name") + valDoc := pluggableDataSourceValue(widget) + if valDoc == nil { + return fmt.Errorf("%q (%s) has no data source property to set", name, widgetTypeName(widget)) + } + var serialized bson.D + switch ds.(type) { + case *pages.MicroflowSource, *pages.NanoflowSource: + if m.deps != nil { + serialized = m.deps.SerializeCustomWidgetDataSource(ds) + } + } + if serialized == nil { + return fmt.Errorf("setting this kind of data source on %q (%s) is not supported by `set` — "+ + "use `replace with …` instead, which rebuilds the widget through the CREATE PAGE path", + name, widgetTypeName(widget)) + } + if !bsonnav.DSet(valDoc, "DataSource", serialized) { + return fmt.Errorf("%q (%s): its data source property has no DataSource slot", name, widgetTypeName(widget)) + } return nil } @@ -1323,6 +1364,12 @@ func widgetOwnDataSourceDoc(wDoc bson.D) bson.D { // property the widget's schema keys "datasource", and returns its DataSource // document. func pluggableDataSourceDoc(widgetDoc bson.D) bson.D { + return bsonnav.DGetDoc(pluggableDataSourceValue(widgetDoc), "DataSource") +} + +// pluggableDataSourceValue returns the Value document of the CustomWidget +// property its schema keys "datasource" — the one holding a DataSource key. +func pluggableDataSourceValue(widgetDoc bson.D) bson.D { obj := bsonnav.DGetDoc(widgetDoc, "Object") if obj == nil { return nil @@ -1344,8 +1391,8 @@ func pluggableDataSourceDoc(widgetDoc bson.D) bson.D { if valDoc == nil { continue } - if dsDoc := bsonnav.DGetDoc(valDoc, "DataSource"); dsDoc != nil { - return dsDoc + if hasKey(valDoc, "DataSource") { + return valDoc } } return nil diff --git a/mdl/backend/pagemutator/set_datasource_1548_test.go b/mdl/backend/pagemutator/set_datasource_1548_test.go new file mode 100644 index 000000000..54ae0cb76 --- /dev/null +++ b/mdl/backend/pagemutator/set_datasource_1548_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// cwSourceDeps serializes a pluggable data source to a marker naming its flow, +// so the test asserts where the source lands without an engine's serializer. +type cwSourceDeps struct{ Deps } + +func (cwSourceDeps) SerializeCustomWidgetDataSource(ds pages.DataSource) bson.D { + switch d := ds.(type) { + case *pages.MicroflowSource: + return bson.D{{Key: "$Type", Value: "Forms$MicroflowSource"}, {Key: "Marker", Value: d.Microflow}} + case *pages.NanoflowSource: + return bson.D{{Key: "$Type", Value: "Forms$NanoflowSource"}, {Key: "Marker", Value: d.Nanoflow}} + } + return nil +} + +// galleryDoc is a CustomWidget whose schema keys one property "datasource", +// stored with a database (XPath) source — the reported starting point. +func galleryDoc(name string) bson.D { + typeID := primitive.Binary{Subtype: 0x04, Data: []byte{ + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, + }} + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: name}, + {Key: "Type", Value: bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, + {Key: "WidgetId", Value: "com.mendix.widget.web.gallery.Gallery"}, + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{int32(2), bson.D{ + {Key: "$ID", Value: typeID}, + {Key: "PropertyKey", Value: "datasource"}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: "DataSource"}}}, + }}}, + }}, + }}, + {Key: "Object", Value: bson.D{ + {Key: "Properties", Value: bson.A{int32(2), bson.D{ + {Key: "TypePointer", Value: typeID}, + {Key: "Value", Value: bson.D{ + {Key: "DataSource", Value: bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidgetXPathSource"}, + }}, + }}, + }}}, + }}, + } +} + +// mendixlabs/mxcli#1548: `set (DataSource: microflow M.F) on galOrders` passed +// check, printed "Altered page" and left the unit byte-identical. A +// CustomWidget keeps its source in the datasource property's Value, not in a +// top-level DataSource key, and the setter's DSet onto that missing key failed +// silently. +func TestSetWidgetDataSource_PluggableMicroflow_1548(t *testing.T) { + m := New(makeRawPage(galleryDoc("galOrders")), model.ID("unit-1"), cwSourceDeps{}) + if err := m.SetWidgetDataSource("galOrders", &pages.MicroflowSource{Microflow: "Shop.DS_Order_Open"}); err != nil { + t.Fatalf("SetWidgetDataSource: %v", err) + } + w := findWidgetForTest(t, m.rawData, "galOrders") + ds := pluggableDataSourceDoc(w) + if got := bsonnav.DGetString(ds, "Marker"); got != "Shop.DS_Order_Open" { + t.Fatalf("pluggable datasource = %v, want the microflow source", ds) + } + if bsonnav.DGet(w, "DataSource") != nil { + t.Error("a CustomWidget must not get a top-level DataSource key") + } +} + +func TestSetWidgetDataSource_PluggableNanoflow_1548(t *testing.T) { + m := New(makeRawPage(galleryDoc("galOrders")), model.ID("unit-1"), cwSourceDeps{}) + if err := m.SetWidgetDataSource("galOrders", &pages.NanoflowSource{Nanoflow: "Shop.NF_Orders"}); err != nil { + t.Fatalf("SetWidgetDataSource: %v", err) + } + if got := bsonnav.DGetString(pluggableDataSourceDoc(findWidgetForTest(t, m.rawData, "galOrders")), "Marker"); got != "Shop.NF_Orders" { + t.Fatalf("pluggable datasource marker = %q, want the nanoflow", got) + } +} + +// A source the pluggable serializer has no shape for is refused, not dropped. +func TestSetWidgetDataSource_PluggableUnsupportedIsRefused_1548(t *testing.T) { + m := New(makeRawPage(galleryDoc("galOrders")), model.ID("unit-1"), cwSourceDeps{}) + err := m.SetWidgetDataSource("galOrders", &pages.ListenToWidgetSource{WidgetName: "dg1"}) + if err == nil { + t.Fatal("expected a refusal for a selection source on a gallery") + } + if !strings.Contains(err.Error(), "galOrders") { + t.Errorf("refusal should name the widget: %v", err) + } +} + +// No setter may report success without writing: a widget with no DataSource +// slot at all is refused. +func TestSetWidgetDataSource_NoSlotIsRefused_1548(t *testing.T) { + txt := bson.D{{Key: "$Type", Value: "Forms$DynamicText"}, {Key: "Name", Value: "txt1"}} + m := New(makeRawPage(txt), model.ID("unit-1"), cwSourceDeps{}) + if err := m.SetWidgetDataSource("txt1", &pages.MicroflowSource{Microflow: "Shop.DS"}); err == nil { + t.Fatal("expected a refusal: a dynamic text has no data source") + } +} From e4f85afd64b234360e0fea9cc7877f477c08c48f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:21:00 +0000 Subject: [PATCH 06/22] fix: ALTER PAGE can address a pluggable widget's slot, e.g. dataGrid1.controlbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a Data grid 2's last control-bar button was dropped, describe printed no `controlbar` and every route back was refused (mendixlabs/mxcli#957): insert into controlBar1 { … } -> widget "controlBar1" not found … insert into dataGrid21 { … } -> cannot INSERT INTO "dataGrid21" (CustomWidgets$CustomWidget): it is not a simple container The bar is the grid's filtersPlaceholder widgets-property: a slot with no Name. The drop never deleted it (Widgets [2] stays in the BSON); describe just omits an empty slot. A pluggable widget's own widgets-typed properties are now addressed like a data view's footer: `.`, using the keyword describe prints (types.WidgetSlotKeyword) or the property key. INSERT INTO appends, REPLACE swaps the content, DROP empties it, BEFORE/AFTER are refused, and a member that also names a column is refused as ambiguous. ResolveAlterTarget accepts the address too. `insert into ` on the grid itself now lists the slot addresses. Control: with the fix reverted the new tests fail with the reported refusals; the unfixed binary answers `insert into dataGrid21.controlbar` with "column \"controlbar\" … not found". Fixed, on a blank 11.13.0 app (stock Administration.Account_Overview, both buttons dropped) the button comes back inside `controlbar { … }` and mx check reports 0 errors. Refs ako/mxcli#1278 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- ...17-datagrid2-controlbar-unaddressable.json | 1 + .claude/skills/mendix/alter-page/SKILL.md | 15 +- docs-site/src/reference/page/alter-page.md | 2 + ...alter-insert-into-datagrid2-controlbar.mdl | 41 +++++ mdl/backend/pagemutator/alter_target.go | 6 +- mdl/backend/pagemutator/mutator.go | 18 ++ .../pagemutator/pluggable_slot_957_test.go | 145 +++++++++++++++ mdl/backend/pagemutator/pluggableslot.go | 167 ++++++++++++++++++ 8 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-datagrid2-controlbar-unaddressable.json create mode 100644 mdl-examples/bug-tests/957-alter-insert-into-datagrid2-controlbar.mdl create mode 100644 mdl/backend/pagemutator/pluggable_slot_957_test.go create mode 100644 mdl/backend/pagemutator/pluggableslot.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-datagrid2-controlbar-unaddressable.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-datagrid2-controlbar-unaddressable.json new file mode 100644 index 000000000..4c91661e4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-datagrid2-controlbar-unaddressable.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-17", "symptom": "after `drop` removed a Data grid 2's last control-bar button, describe printed no `controlbar` and every route back was refused: `insert into controlBar1` (\"widget not found\", with a DataGrid2-columns hint) and `insert into dataGrid21` (\"not a simple container\", reported as CustomWidgets$CustomWidget)", "cause": "the control bar is the grid's filtersPlaceholder widgets-property — a slot with no Name, not a widget — and no dotted address reached a pluggable widget's own slots; describe omits an empty slot, which made the drop look like it deleted the bar (it does not: Widgets [2] stays)", "file": "`mdl/backend/pagemutator/pluggableslot.go` (new), `alter_target.go`, `mutator.go` (`InsertWidget`/`ReplaceWidget`/`DropWidget`); slot keyword table moved to `mdl/types/widget_slot_keywords.go`", "insight": "The reporter called it a classic grid; the error's $Type (CustomWidgets$CustomWidget) was the tell that it is a Data grid 2 whose filtersPlaceholder describe spells `controlbar`. Diff the BSON before and after the drop before believing 'the bar was deleted' — the slot survived, describe hid it. Address a slot like a data view footer (`grid.controlbar`), resolving the keyword through the same table describe/create use (moved to mdl/types so the mutator cannot drift from it), and wire the generic ALTER resolver (ResolveAlterTarget) as well as the operation — the first end-to-end run passed the mutator tests and still failed at 'failed to resolve page target'", "refs": ["mendixlabs/mxcli#957", "ako/mxcli#1278"]} diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 3af0eaa3d..91c1741ca 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -17,10 +17,10 @@ description: "Modify an existing page or snippet's widget tree in place with ALT | 474-552 | Examples | | 553-643 | DataGrid 2 columns: how to address them, and what you can set | | 644-655 | Common Mistakes | -| 656-677 | Limitations — prefer binding at page creation (ledger finding #45) | -| 678-685 | Validation Checklist | -| 686-695 | Related Commands | -| 696-700 | Related Skills | +| 656-684 | Limitations — prefer binding at page creation (ledger finding #45) | +| 685-692 | Validation Checklist | +| 693-702 | Related Commands | +| 703-707 | Related Skills | # ALTER PAGE / ALTER SNIPPET - Modify Existing Pages and Snippets @@ -670,6 +670,13 @@ page and bind the buttons at creation time instead of rewiring afterwards: view: `replace dvMain.footer with { footer { … } }`, `insert into dvMain.footer { … }`, `drop dvMain.footer`. +**A pluggable widget's slot has no name either.** A Data grid 2's control bar + (`controlbar { … }` in describe) is a property of the grid, so address it by + the grid: `insert into dataGrid1.controlbar { actionbutton … }`, `replace + dataGrid1.controlbar with { … }`, `drop dataGrid1.controlbar`. This is the + way back in after the bar's last button was dropped — describe then prints + no `controlbar` block, but the slot is still there. + **Recommended pattern**: put save/reset microflows in a file that runs *before* the page definition, and bind the popup/footer buttons to them at creation. The apparent "page needs microflow, microflow needs page" cycle usually exists only diff --git a/docs-site/src/reference/page/alter-page.md b/docs-site/src/reference/page/alter-page.md index 7db5e5b76..ca8dae3c0 100644 --- a/docs-site/src/reference/page/alter-page.md +++ b/docs-site/src/reference/page/alter-page.md @@ -92,6 +92,8 @@ Appends new widgets as the **last children** of a named container. This is the o Supported on simple containers (container/DivContainer, data view, group box, tab page, scroll-container region). A layout grid (rows/columns) has no single child list — insert relative to a widget inside the target column instead. A tab container's children are tab pages; see below. +A pluggable widget's child-widget slots have no name of their own, so they are addressed by the widget and the slot keyword `describe` prints: `insert into dataGrid1.controlbar { actionbutton … }` adds to a Data grid 2's control bar (even after its last button was dropped), `replace dataGrid1.controlbar with { … }` swaps its content, and `drop dataGrid1.controlbar` empties it. The widget's own property key (`dataGrid1.filtersPlaceholder`) works too. `insert into dataGrid1` on the widget itself is refused with the slot addresses it has. + ### Adding Tab Pages A tab container holds tab pages, and only tab pages. `INSERT INTO` the tab diff --git a/mdl-examples/bug-tests/957-alter-insert-into-datagrid2-controlbar.mdl b/mdl-examples/bug-tests/957-alter-insert-into-datagrid2-controlbar.mdl new file mode 100644 index 000000000..f72e795ac --- /dev/null +++ b/mdl-examples/bug-tests/957-alter-insert-into-datagrid2-controlbar.mdl @@ -0,0 +1,41 @@ +mdl 1; +-- mendixlabs/mxcli#957: after `drop` removed a Data grid 2's last control-bar +-- button, describe printed no `controlbar` block and every route back was +-- refused: `insert into controlBar1` ("not found" — the bar stores no name) +-- and `insert into dataGrid1` ("not a simple container"). The slot itself was +-- still in the model, empty. +-- +-- A pluggable widget's slot is now addressed by the widget and the keyword +-- describe prints, like a data view's footer: `dataGrid1.controlbar`. +-- +-- Expected: the describe below shows dgItems with +-- controlbar { actionbutton btnAdd (Caption: 'Add', …) } + +create module Bug957; + +create persistent entity Bug957.Item (Name: string(50)); + +create page Bug957.Items ( + title: 'Items', + layout: Atlas_Core.Atlas_Default +) { + datagrid dgItems (DataSource: database Bug957.Item) { + controlbar { + actionbutton btnOld (Caption: 'Old', Action: close page) + } + column (Attribute: Name) + } +}; + +-- Empty the bar, then bring a button back into it. +alter page Bug957.Items { + drop btnOld +}; + +alter page Bug957.Items { + insert into dgItems.controlbar { + actionbutton btnAdd (Caption: 'Add', Action: close page) + } +}; + +describe page Bug957.Items; diff --git a/mdl/backend/pagemutator/alter_target.go b/mdl/backend/pagemutator/alter_target.go index a8a990039..1ffd1890e 100644 --- a/mdl/backend/pagemutator/alter_target.go +++ b/mdl/backend/pagemutator/alter_target.go @@ -14,7 +14,8 @@ import ( // snippet and layout alike — one widget tree, one address syntax). // // A page element is addressed by name: `btnSave`, `grid.Column`, or a scroll -// container's region, `layoutContainer.top`. The lookups are the ones the +// container's region, `layoutContainer.top`, or a pluggable widget's slot, +// `dataGrid21.controlbar`. The lookups are the ones the // operations themselves make, so resolution cannot accept a target the // operation then misses, and a miss reports what the operation would have // reported. @@ -31,6 +32,9 @@ func (m *Mutator) ResolveAlterTarget(t backend.AlterTarget) (backend.AlterTarget if _, ok := m.findDataViewFooter(container, member); ok { return backend.AlterTargetMatch{Kind: "region", Name: name}, nil } + if _, ok, err := m.findPluggableSlot(container, member); ok { + return backend.AlterTargetMatch{Kind: "region", Name: name}, err + } if _, err := findBsonColumn(m.rawData, container, member, m.widgetFinder); err != nil { return backend.AlterTargetMatch{}, err } diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 26a95a2a9..d1831d262 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -460,6 +460,9 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back if handled, err := m.insertIntoDataViewFooter(widgetRef, columnRef, position, widgets); handled { return err } + if handled, err := m.insertIntoPluggableSlot(widgetRef, columnRef, position, widgets); handled { + return err + } // Resolve first, so a mistyped column still reports "not found" (with the // available names) rather than the refusal below. if _, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder); err != nil { @@ -498,6 +501,10 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back if strings.EqualFold(string(position), "into") { newContainer, err := appendChildrenToContainer(result.widget, widgetRef, newBsonWidgets) if err != nil { + if hint := pluggableSlotHint(result, widgetRef); hint != "" { + return fmt.Errorf("cannot INSERT INTO %q: a pluggable widget keeps its child widgets in named slots, "+ + "not in a list of its own — use %s", widgetRef, hint) + } return err } // Write the (possibly reallocated — an empty container gains a new Widgets @@ -602,6 +609,14 @@ func (m *Mutator) DropWidget(refs []backend.WidgetRef) error { if ref.IsColumn() && m.dropDataViewFooter(ref.Widget, ref.Column) { continue } + if ref.IsColumn() { + if handled, err := m.dropPluggableSlot(ref.Widget, ref.Column); handled { + if err != nil { + return err + } + continue + } + } if ref.IsColumn() { r, err := findBsonColumn(m.rawData, ref.Widget, ref.Column, m.widgetFinder) if err != nil { @@ -633,6 +648,9 @@ func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pa if handled, err := m.replaceDataViewFooter(widgetRef, columnRef, widgets); handled { return err } + if handled, err := m.replacePluggableSlot(widgetRef, columnRef, widgets); handled { + return err + } // Resolve first, so a mistyped column still reports "not found" (with the // available names) rather than the refusal below. if _, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder); err != nil { diff --git a/mdl/backend/pagemutator/pluggable_slot_957_test.go b/mdl/backend/pagemutator/pluggable_slot_957_test.go new file mode 100644 index 000000000..bc4a92806 --- /dev/null +++ b/mdl/backend/pagemutator/pluggable_slot_957_test.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// dataGrid2WithControlBar is a Data grid 2 whose filtersPlaceholder — the slot +// describe prints as `controlbar { … }` — holds the named buttons, stored as +// Studio Pro leaves it once its last button is gone: an empty Widgets [2]. +func dataGrid2WithControlBar(buttons ...string) bson.D { + typeID := primitive.Binary{Subtype: 0x04, Data: []byte{ + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, + }} + widgets := bson.A{int32(2)} + for _, b := range buttons { + widgets = append(widgets, bson.D{{Key: "$Type", Value: "Forms$ActionButton"}, {Key: "Name", Value: b}}) + } + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: "dataGrid21"}, + {Key: "Type", Value: bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, + {Key: "WidgetId", Value: "com.mendix.widget.web.datagrid.Datagrid"}, + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{int32(2), bson.D{ + {Key: "$ID", Value: typeID}, + {Key: "PropertyKey", Value: "filtersPlaceholder"}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: "Widgets"}}}, + }}}, + }}, + }}, + {Key: "Object", Value: bson.D{ + {Key: "Properties", Value: bson.A{int32(2), bson.D{ + {Key: "TypePointer", Value: typeID}, + {Key: "Value", Value: bson.D{{Key: "Widgets", Value: widgets}}}, + }}}, + }}, + } +} + +func controlBarNames(t *testing.T, m *Mutator) []string { + t.Helper() + grid := findWidgetForTest(t, m.rawData, "dataGrid21") + prop := bsonnav.DGetArrayElements(bsonnav.DGet(bsonnav.DGetDoc(grid, "Object"), "Properties"))[0].(bson.D) + raw := bsonnav.ToBsonA(bsonnav.DGet(bsonnav.DGetDoc(prop, "Value"), "Widgets")) + if len(raw) == 0 || raw[0] != int32(2) { + t.Fatalf("filtersPlaceholder Widgets lost its [2] marker: %v", raw) + } + var names []string + for _, w := range raw[1:] { + names = append(names, bsonnav.DGetString(w.(bson.D), "Name")) + } + return names +} + +func newButton(name string) pages.Widget { + b := &pages.ActionButton{} + b.Name = name + return b +} + +// mendixlabs/mxcli#957: once the grid's last control-bar button was dropped, +// every route back was refused — `insert into controlBar1` ("not found": the +// bar has no name) and `insert into dataGrid21` ("not a simple container"). +// The slot is addressed the way a data view's footer is: `dataGrid21.controlbar`. +func TestInsertIntoDataGrid2ControlBar_957(t *testing.T) { + for _, member := range []string{"controlbar", "ControlBar", "filtersPlaceholder"} { + m := New(makeRawPage(dataGrid2WithControlBar()), model.ID("u"), &stubWidgetDeps{}) + if err := m.InsertWidget("dataGrid21", member, backend.InsertPosition("into"), []pages.Widget{newButton("btnNew")}); err != nil { + t.Fatalf("insert into dataGrid21.%s: %v", member, err) + } + if got := controlBarNames(t, m); len(got) != 1 || got[0] != "btnNew" { + t.Errorf("dataGrid21.%s after insert = %v, want [btnNew]", member, got) + } + } +} + +func TestInsertIntoDataGrid2ControlBarAppends_957(t *testing.T) { + m := New(makeRawPage(dataGrid2WithControlBar("btnA")), model.ID("u"), &stubWidgetDeps{}) + if err := m.InsertWidget("dataGrid21", "controlbar", backend.InsertPosition("into"), []pages.Widget{newButton("btnB")}); err != nil { + t.Fatal(err) + } + if got := strings.Join(controlBarNames(t, m), ","); got != "btnA,btnB" { + t.Errorf("control bar = %s, want btnA,btnB", got) + } +} + +func TestDataGrid2ControlBarRefusesBeforeAfter_957(t *testing.T) { + m := New(makeRawPage(dataGrid2WithControlBar()), model.ID("u"), &stubWidgetDeps{}) + err := m.InsertWidget("dataGrid21", "controlbar", backend.InsertPosition("after"), []pages.Widget{newButton("btnNew")}) + if err == nil || !strings.Contains(err.Error(), "INSERT INTO") { + t.Fatalf("insert after a slot should be refused with the INTO hint, got %v", err) + } +} + +func TestReplaceAndDropDataGrid2ControlBar_957(t *testing.T) { + m := New(makeRawPage(dataGrid2WithControlBar("btnA", "btnB")), model.ID("u"), &stubWidgetDeps{}) + if err := m.ReplaceWidget("dataGrid21", "controlbar", []pages.Widget{newButton("btnC")}); err != nil { + t.Fatal(err) + } + if got := strings.Join(controlBarNames(t, m), ","); got != "btnC" { + t.Errorf("after replace = %s, want btnC", got) + } + if err := m.DropWidget([]backend.WidgetRef{{Widget: "dataGrid21", Column: "controlbar"}}); err != nil { + t.Fatal(err) + } + if got := controlBarNames(t, m); len(got) != 0 { + t.Errorf("after drop = %v, want empty (slot kept)", got) + } +} + +// `insert into dataGrid21` names the grid itself; the refusal now points at +// the slot address that works instead of a widget inside a column or tab. +func TestInsertIntoDataGrid2ItselfNamesTheSlot_957(t *testing.T) { + m := New(makeRawPage(dataGrid2WithControlBar()), model.ID("u"), &stubWidgetDeps{}) + err := m.InsertWidget("dataGrid21", "", backend.InsertPosition("into"), []pages.Widget{newButton("btnNew")}) + if err == nil || !strings.Contains(err.Error(), "dataGrid21.controlbar") { + t.Fatalf("want a refusal naming dataGrid21.controlbar, got %v", err) + } +} + +// The generic ALTER resolves the target before the operation runs; it must +// accept the slot address the operation accepts. +func TestResolveAlterTargetDataGrid2ControlBar_957(t *testing.T) { + m := New(makeRawPage(dataGrid2WithControlBar()), model.ID("u"), &stubWidgetDeps{}) + got, err := m.ResolveAlterTarget(backend.AlterTarget{Path: []string{"dataGrid21", "controlbar"}}) + if err != nil { + t.Fatalf("ResolveAlterTarget(dataGrid21.controlbar): %v", err) + } + if got.Kind != "region" { + t.Errorf("kind = %q, want region", got.Kind) + } +} diff --git a/mdl/backend/pagemutator/pluggableslot.go b/mdl/backend/pagemutator/pluggableslot.go new file mode 100644 index 000000000..3e4337be4 --- /dev/null +++ b/mdl/backend/pagemutator/pluggableslot.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "fmt" + "sort" + "strings" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// A pluggable widget's widgets-typed property — a Data grid 2's control bar +// (filtersPlaceholder), a Gallery's template or filter — is a SLOT, not a +// widget: it stores no Name, and describe prints it as a keyword block +// (`controlbar { … }`). So once its last widget was dropped nothing could +// reach it again: `insert into controlBar1` was "not found" and `insert into +// dataGrid21` "not a simple container" (mendixlabs/mxcli#957). +// +// It is addressed the way a data view's footer is — positionally, by owner and +// slot: `dataGrid21.controlbar`, spelled with the keyword describe prints +// (types.WidgetSlotKeyword) or the property key itself. INSERT INTO appends, +// REPLACE swaps the whole content, DROP empties it; the slot itself stays, as +// the property is mandatory. BEFORE/AFTER a slot mean nothing and are refused. + +// pluggableSlot is one widgets-typed property of a pluggable widget's Object. +type pluggableSlot struct { + owner *bsonWidgetResult + keyword string // lower-case MDL keyword, or the property key + alias bool // keyword is the property key, not describe's spelling + value bson.D // the property's WidgetValue, holding Widgets +} + +// pluggableSlots lists the widget's own widgets-typed properties (not those of +// its object-list items — an Accordion group's content is not the Accordion's). +func pluggableSlots(owner *bsonWidgetResult) []pluggableSlot { + if bsonnav.DGetString(owner.widget, "$Type") != "CustomWidgets$CustomWidget" { + return nil + } + keys := buildPropKeyMap(owner.widget) + kinds := buildPropKindMap(owner.widget) + widgetID := pluggableWidgetID(owner.widget) + var out []pluggableSlot + for _, prop := range bsonnav.DGetArrayElements(bsonnav.DGet(bsonnav.DGetDoc(owner.widget, "Object"), "Properties")) { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + tp := bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(propDoc, "TypePointer")) + if kinds[tp] != "Widgets" || keys[tp] == "" { + continue + } + value := bsonnav.DGetDoc(propDoc, "Value") + if value == nil || !hasKey(value, "Widgets") { + continue + } + keyword := strings.ToLower(types.WidgetSlotKeyword(widgetID, keys[tp])) + out = append(out, pluggableSlot{owner: owner, keyword: keyword, value: value}) + if !strings.EqualFold(keyword, keys[tp]) { + // Also answer to the property key, the name the widget's own + // definition uses for it. + out = append(out, pluggableSlot{owner: owner, keyword: strings.ToLower(keys[tp]), alias: true, value: value}) + } + } + return out +} + +// findPluggableSlot resolves `.`. ok is false when member names no +// slot of a pluggable owner, so the caller falls through to the other dotted +// forms. A member that also resolves as a Data grid 2 column is refused rather +// than guessed. +func (m *Mutator) findPluggableSlot(owner, member string) (slot pluggableSlot, ok bool, err error) { + result := m.widgetFinder(m.rawData, owner) + if result == nil { + return pluggableSlot{}, false, nil + } + for _, s := range pluggableSlots(result) { + if !strings.EqualFold(s.keyword, member) { + continue + } + if _, colErr := findBsonColumn(m.rawData, owner, member, m.widgetFinder); colErr == nil { + return s, true, fmt.Errorf("%s.%s is ambiguous: it names both the %s slot of %q and one of its "+ + "columns — address the column by its other name (attribute or caption)", owner, member, s.keyword, owner) + } + return s, true, nil + } + return pluggableSlot{}, false, nil +} + +// widgets is the slot's current content, without the list marker. +func (s pluggableSlot) widgets() []any { + return bsonnav.DGetArrayElements(bsonnav.DGet(s.value, "Widgets")) +} + +// set stores the slot's new content, keeping its stored list marker. The +// Widgets key exists (pluggableSlots requires it), so DSet writes in place. +func (s pluggableSlot) set(widgets []any) { + out := bson.A{int32(2)} + if raw := bsonnav.ToBsonA(bsonnav.DGet(s.value, "Widgets")); len(raw) > 0 && isListMarker(raw[0]) { + out = bson.A{raw[0]} + } + bsonnav.DSet(s.value, "Widgets", append(out, widgets...)) +} + +// insertIntoPluggableSlot handles `insert into . { … }`. +func (m *Mutator) insertIntoPluggableSlot(owner, member string, position backend.InsertPosition, widgets []pages.Widget) (bool, error) { + slot, ok, err := m.findPluggableSlot(owner, member) + if !ok || err != nil { + return ok, err + } + if !strings.EqualFold(string(position), "into") { + return true, fmt.Errorf("%s.%s is a slot of the widget, not a widget, so it can only be an INSERT INTO target; "+ + "to place a widget relative to another, name that widget: `insert %s { … }`", + owner, member, strings.ToLower(string(position))) + } + newBson, err := m.serializeWidgets(widgets) + if err != nil { + return true, fmt.Errorf("serialize widgets: %w", err) + } + slot.set(append(append([]any{}, slot.widgets()...), newBson...)) + return true, nil +} + +// replacePluggableSlot handles `replace . with { … }`. +func (m *Mutator) replacePluggableSlot(owner, member string, widgets []pages.Widget) (bool, error) { + slot, ok, err := m.findPluggableSlot(owner, member) + if !ok || err != nil { + return ok, err + } + newBson, err := m.serializeWidgets(widgets) + if err != nil { + return true, fmt.Errorf("serialize widgets: %w", err) + } + slot.set(newBson) + return true, nil +} + +// dropPluggableSlot handles `drop .`: the slot is emptied. +func (m *Mutator) dropPluggableSlot(owner, member string) (bool, error) { + slot, ok, err := m.findPluggableSlot(owner, member) + if !ok || err != nil { + return ok, err + } + slot.set(nil) + return true, nil +} + +// pluggableSlotHint names the slot addresses a pluggable widget has, for the +// refusal of `insert into ` on the widget itself. +func pluggableSlotHint(owner *bsonWidgetResult, name string) string { + var addrs []string + for _, s := range pluggableSlots(owner) { + if !s.alias { + addrs = append(addrs, "`insert into "+name+"."+s.keyword+" { … }`") + } + } + if len(addrs) == 0 { + return "" + } + sort.Strings(addrs) + return strings.Join(addrs, ", ") +} From 5b8e305fa4dc3fc05187cb5896de1545c6cd785e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:21:03 +0000 Subject: [PATCH 07/22] fix: ALTER PAGE reaches widgets inside legacy table cells set / insert before|after / replace / drop on a widget inside a legacy Forms$Table cell reported `widget "X" not found` (mendixlabs/mxcli#1453): none of the three child walkers (the finder, the data-source walk, the widget-scope collector) descended into Cells[].Widgets[]. All three now do. The descent is keyed on the Cells list rather than a $Type, because a Mendix 11 cell is stored as Forms$DbTableCell, which gen does not model; Forms$Table is the only page type with a Cells list. Control: with the walk reverted, the new tests fail with `widget "btnInCell" not found` and, for the scope collector alone, "btnInCell missing from the widget scope". Not verified against a Studio Pro-authored table: none exists in testdata, MDL cannot create one, and mx convert refuses Forms$TableCell (widget_list_markers.go). For the same reason there is no bug-test script. A wrong guess at the key fails safe as the same not-found. `insert into` an empty cell still has no address; a cell stores no name. Refs ako/mxcli#1326 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- ...legacy-table-cell-widgets-unreachable.json | 1 + .../pagemutator/legacy_table_1453_test.go | 96 +++++++++++++++++++ mdl/backend/pagemutator/mutator.go | 13 +++ 3 files changed, 110 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-legacy-table-cell-widgets-unreachable.json create mode 100644 mdl/backend/pagemutator/legacy_table_1453_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-legacy-table-cell-widgets-unreachable.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-legacy-table-cell-widgets-unreachable.json new file mode 100644 index 000000000..be48c4110 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-legacy-table-cell-widgets-unreachable.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-17", "symptom": "alter page/snippet set / insert / replace / drop on a widget inside a legacy table cell reports `widget \"X\" not found`; field scripts fell back to BSON patches", "cause": "findInWidgetChildren, findNearestDSInChildren and collectWidgetScopeInChildren walked every other child list but not a Forms$Table's Cells[].Widgets[]", "file": "`mdl/backend/pagemutator/mutator.go` (the three child walkers)", "insight": "Three walkers must learn every new child list together (the same trio #891 and #1388 each extended); a finder-only fix resolves the target but builds inserted widgets without the cell's data context. Keyed on the `Cells` list, not on $Type: a Mendix 11 cell is Forms$DbTableCell, which gen does not model (widget_list_markers.go), and Forms$Table is the only page type with a Cells list. Not yet run against a Studio Pro-authored table — none exists in testdata and MDL cannot create one; a wrong key guess fails safe as the same not-found", "refs": ["mendixlabs/mxcli#1453", "ako/mxcli#1326"]} diff --git a/mdl/backend/pagemutator/legacy_table_1453_test.go b/mdl/backend/pagemutator/legacy_table_1453_test.go new file mode 100644 index 000000000..08f5b331c --- /dev/null +++ b/mdl/backend/pagemutator/legacy_table_1453_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// legacyTable is a Forms$Table whose one cell holds the named widgets. The +// table keeps its widgets in Cells[].Widgets[] (gen's Forms$Table / TableCell; +// a Mendix 11 cell is stored as Forms$DbTableCell, which `mx convert` writes +// with a Widgets list — see widget_list_markers.go), so the cell's $Type is +// deliberately not one the walk could special-case. +func legacyTable(inner ...bson.D) bson.D { + widgets := bson.A{int32(2)} + for _, w := range inner { + widgets = append(widgets, w) + } + return bson.D{ + {Key: "$Type", Value: "Forms$Table"}, + {Key: "Name", Value: "table1"}, + {Key: "Cells", Value: bson.A{int32(2), bson.D{ + {Key: "$Type", Value: "Forms$DbTableCell"}, + {Key: "LeftColumnIndex", Value: int32(0)}, + {Key: "TopRowIndex", Value: int32(0)}, + {Key: "Widgets", Value: widgets}, + }}}, + {Key: "Columns", Value: bson.A{int32(2)}}, + {Key: "Rows", Value: bson.A{int32(2)}}, + } +} + +func cellWidgetNames(t *testing.T, raw bson.D) []string { + t.Helper() + table := findWidgetForTest(t, raw, "table1") + cell := bsonnav.DGetArrayElements(bsonnav.DGet(table, "Cells"))[0].(bson.D) + var names []string + for _, w := range bsonnav.DGetArrayElements(bsonnav.DGet(cell, "Widgets")) { + names = append(names, bsonnav.DGetString(w.(bson.D), "Name")) + } + return names +} + +func menuItem(name string) bson.D { + return bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Forms$ActionButton"}, + {Key: "Name", Value: name}, + {Key: "Appearance", Value: bson.D{{Key: "$Type", Value: "Forms$Appearance"}, {Key: "Class", Value: ""}}}, + } +} + +// mendixlabs/mxcli#1453: set / insert / replace / drop could not reach a +// widget inside a legacy table cell — findInWidgetChildren had no Cells walk. +func TestFindWidgetInLegacyTableCell_1453(t *testing.T) { + raw := makeRawPage(legacyTable(menuItem("btnInCell"))) + if findBsonWidget(raw, "btnInCell") == nil { + t.Fatal(`widget "btnInCell" inside a table cell not found`) + } + if _, ok := extractWidgetScopeFromBSON(raw)["btnInCell"]; !ok { + t.Error("btnInCell missing from the widget scope") + } +} + +func TestSetInsertDropInLegacyTableCell_1453(t *testing.T) { + m := New(makeRawPage(legacyTable(menuItem("btnInCell"))), model.ID("u"), &stubWidgetDeps{}) + if err := m.SetWidgetProperty("btnInCell", "Class", "probe"); err != nil { + t.Fatalf("set Class on btnInCell: %v", err) + } + if err := m.InsertWidget("btnInCell", "", backend.InsertPosition("after"), []pages.Widget{newCellWidget("btnNew")}); err != nil { + t.Fatalf("insert after btnInCell: %v", err) + } + if got := cellWidgetNames(t, m.rawData); len(got) != 2 || got[1] != "btnNew" { + t.Fatalf("cell after insert = %v, want [btnInCell btnNew]", got) + } + if err := m.DropWidget([]backend.WidgetRef{{Widget: "btnInCell"}}); err != nil { + t.Fatalf("drop btnInCell: %v", err) + } + if got := cellWidgetNames(t, m.rawData); len(got) != 1 || got[0] != "btnNew" { + t.Errorf("cell after drop = %v, want [btnNew]", got) + } +} + +func newCellWidget(name string) pages.Widget { + w := &pages.ActionButton{} + w.Name = name + return w +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 26a95a2a9..781f8598e 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -1522,6 +1522,15 @@ func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { return result } + // Legacy table: Cells[].Widgets[] (mendixlabs/mxcli#1453). A cell has no + // Name, so findInWidgetArray descends straight into its Widgets. Keyed on + // the list, not the $Type: Mendix 11 stores a cell as Forms$DbTableCell, + // which gen does not model, and Forms$Table is the only page type with a + // Cells list. + if result := findInWidgetArray(wDoc, "Cells", widgetName); result != nil { + return result + } + // NavigationList: Items[] hold no Name, but the widgets inside each one do // (mendixlabs/mxcli#1388); findInWidgetArray descends into Items[].Widgets[]. if isNavigationList(wDoc) { @@ -2302,6 +2311,9 @@ func findNearestDSInChildren(wDoc bson.D, widgetName string, curDS bson.D) (bson if ds, found := findNearestDSInWidgets(wDoc, "TabPages", widgetName, curDS); found { return ds, true } + if ds, found := findNearestDSInWidgets(wDoc, "Cells", widgetName, curDS); found { + return ds, true + } if isNavigationList(wDoc) { if ds, found := findNearestDSInWidgets(wDoc, "Items", widgetName, curDS); found { return ds, true @@ -2504,6 +2516,7 @@ func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) { } } collectWidgetScope(wDoc, "TabPages", scope) + collectWidgetScope(wDoc, "Cells", scope) // legacy table cells (#1453) if isNavigationList(wDoc) { collectWidgetScope(wDoc, "Items", scope) } From fa3db73275c8c6a61b9d4435eade0e52b37cdf1b Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 17 Oct 2026 18:22:24 +0200 Subject: [PATCH 08/22] fix: describe keeps statements after an if, and loop bodies, inside an error handler (#1465) The error-handler walk stopped at the first merge with more than one way in, which is the merge closing an `if` in the handler body, so everything after `end if;` was dropped and drop + create rerouted the handler into the main flow. Stop instead at the first merge with a way in from outside the handler. Loops in a handler printed only their header; emit the body. Fixes #1456 (mendixlabs/mxcli#1559). Claude-Session: https://claude.ai/code/session_01JgXjVr9Tw5KRof2jj4JyAv Co-authored-by: Claude --- ...-drops-tail-after-if-loop-header-only.json | 1 + ...1456-error-handler-compound-statements.mdl | 53 +++++++++++ ...croflows_describe_handler_compound_test.go | 92 +++++++++++++++++++ mdl/executor/cmd_microflows_show_helpers.go | 45 ++++++++- 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-handler-drops-tail-after-if-loop-header-only.json create mode 100644 mdl-examples/bug-tests/1456-error-handler-compound-statements.mdl create mode 100644 mdl/executor/cmd_microflows_describe_handler_compound_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-handler-drops-tail-after-if-loop-header-only.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-handler-drops-tail-after-if-loop-header-only.json new file mode 100644 index 000000000..2116b4177 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-handler-drops-tail-after-if-loop-header-only.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-17","symptom":"Inside a custom error handler, `describe microflow` printed nothing after an `if … end if;` (\"The description goes from `end if;` straight to `end error;`\") and printed a loop as its bare header (`loop $Order in $Orders`, no begin/body/end loop). No warning; `create or modify` said Unchanged; drop + create rerouted the handler's tail into the main flow (a failed call logged `done` and returned 1), `mx check` clean. The loop shape did not parse","cause":"Two gaps in the handler walk (`collectErrorHandlerStatementSpans`), a second, smaller describer. (1) Its stop, `firstReachableErrorHandlerMerge`, was \"the first merge with more than one way in\" — and the merge closing an `if` in the handler body is exactly that, so it became the boundary and the split's continue-after-merge step was skipped (`nestedMergeID == boundary`). (2) It formatted a LoopedActivity with `formatActivity`, i.e. the header only, never calling `emitLoopBody`","file":"`mdl/executor/cmd_microflows_show_helpers.go` (`firstReachableErrorHandlerMerge`, the traverse closure in `collectErrorHandlerStatementSpans`)","insight":"The handler's exit is the first merge with a way in from OUTSIDE the handler (any origin not reachable from the handler start over normal flows), not the first multi-entry merge; the old no-op-merge rule (#1078 follow-up) is the one-way-in special case of this. Ask which walk disagrees: the main describer and the handler walk each re-implement compound statements, and every shape the handler walk lacks (loop body here, nested custom handlers still) is a silent drop there. `assertDescriptionRebuildsGraph` caught the reroute as `invented flow … -> LogMessageAction(main)` — use it for every handler shape. Controls: dropping the `joinsOutside` check fails both if-tests, disabling the loop branch fails the loop test. E2E on 11.12.4: reporter's script, describe → drop+create → describe is byte-identical, `mxcli docker check` passes. Not fixed here: an `if` without `else` in a handler still prints an empty `else` (parses, rebuilds the same graph)","refs":["#1456","mendixlabs/mxcli#1559","#1078"]} diff --git a/mdl-examples/bug-tests/1456-error-handler-compound-statements.mdl b/mdl-examples/bug-tests/1456-error-handler-compound-statements.mdl new file mode 100644 index 000000000..77cb7817f --- /dev/null +++ b/mdl-examples/bug-tests/1456-error-handler-compound-statements.mdl @@ -0,0 +1,53 @@ +-- ako/mxcli#1456 (mendixlabs/mxcli#1559): inside a custom error handler, +-- `describe microflow` dropped everything after an `if … end if;` and printed a +-- loop as its bare header. Rebuilding the description (drop + create) rerouted +-- the handler's merge into the main flow, so a failed call went on with the +-- main path; the loop shape did not parse. +-- +-- Expected: both descriptions contain the whole handler — `log … 'after'; +-- return -1;` after `end if;`, and the loop with its body and `end loop;`. +mdl 1; +create module MyModule; +create persistent entity MyModule.Order (Number: String(20)); +create persistent entity MyModule.Result (Message: String(200)); + +create microflow MyModule.Ping ($Code: Integer) returns Integer +begin + return $Code; +end; + +create microflow MyModule.Order_Notify ($Flag: Boolean) returns Integer +begin + $Response = call rest service get 'https://example.com/orders' ( + Timeout: 30, + ) returns response + on error without rollback begin + if $Flag then + log info node 'MyModule' 'one'; + else + log warning node 'MyModule' 'two'; + end if; + log info node 'MyModule' 'after'; + return -1; + end error; + log info node 'MyModule' 'done'; + return 1; +end; + +create microflow MyModule.Order_Collect () returns List of MyModule.Result +begin + retrieve $Orders from MyModule.Order; + $Results = create list of MyModule.Result; + $Code = call microflow MyModule.Ping(Code = 1) + on error without rollback begin + loop $Order in $Orders begin + $Item = create MyModule.Result (Message = $Order/Number); + add $Item to $Results; + end loop; + return $Results; + end error; + return $Results; +end; + +describe microflow MyModule.Order_Notify; +describe microflow MyModule.Order_Collect; diff --git a/mdl/executor/cmd_microflows_describe_handler_compound_test.go b/mdl/executor/cmd_microflows_describe_handler_compound_test.go new file mode 100644 index 000000000..5784719cf --- /dev/null +++ b/mdl/executor/cmd_microflows_describe_handler_compound_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// A compound statement inside a custom error handler (ako/mxcli#1456, +// mendixlabs/mxcli#1559). The handler walk stopped at the first merge it met, +// and the merge closing an `if` inside the handler is exactly that — so +// everything after `end if;` was dropped, and rebuilding the description +// rerouted the handler into the main flow. A loop came out as its bare header. +const handlerIfThenMoreMDL = `create microflow M.HandlerIf ($Flag: Boolean) returns Integer +begin + $X = call microflow M.Sub(In = 1) on error without rollback begin + if $Flag then + log info node 'T' 'one'; + else + log warning node 'T' 'two'; + end if; + log info node 'T' 'after'; + return -1; + end error; + log info node 'T' 'done'; + return 1; +end;` + +func TestDescribeErrorHandler_StatementsAfterIfAreKept(t *testing.T) { + out, oc := describeBuilt(t, handlerIfThenMoreMDL) + handler := handlerBlock(t, out) + for _, want := range []string{"end if;", "'after'", "return -1;"} { + if !strings.Contains(handler, want) { + t.Errorf("handler lost %q after `end if;`:\n%s", want, out) + } + } + assertDescriptionRebuildsGraph(t, out, oc) +} + +const handlerIfNoElseMDL = `create microflow M.HandlerIfNoElse ($Flag: Boolean) returns Integer +begin + $X = call microflow M.Sub(In = 1) on error without rollback begin + if $Flag then + log info node 'T' 'one'; + end if; + return -1; + end error; + return 1; +end;` + +func TestDescribeErrorHandler_ReturnAfterIfWithoutElseIsKept(t *testing.T) { + out, oc := describeBuilt(t, handlerIfNoElseMDL) + if !strings.Contains(handlerBlock(t, out), "return -1;") { + t.Errorf("handler lost its `return` after `end if;`:\n%s", out) + } + assertDescriptionRebuildsGraph(t, out, oc) +} + +const handlerLoopMDL = `create microflow M.HandlerLoop ($Items: List of M.Item) returns Integer +begin + $X = call microflow M.Sub(In = 1) on error without rollback begin + loop $Item in $Items begin + log info node 'T' 'inside'; + end loop; + return -1; + end error; + return 1; +end;` + +func TestDescribeErrorHandler_LoopKeepsItsBody(t *testing.T) { + out, oc := describeBuilt(t, handlerLoopMDL) + handler := handlerBlock(t, out) + for _, want := range []string{"loop $Item in $Items", "begin", "'inside'", "end loop;", "return -1;"} { + if !strings.Contains(handler, want) { + t.Errorf("handler loop is missing %q:\n%s", want, out) + } + } + assertDescriptionRebuildsGraph(t, out, oc) +} + +// handlerBlock returns the text between the first `on error … begin` and its +// `end error;`. +func handlerBlock(t *testing.T, out string) string { + t.Helper() + start := strings.Index(out, "on error without rollback begin") + end := strings.Index(out, "end error;") + if start < 0 || end < start { + t.Fatalf("no error handler block in description:\n%s", out) + } + return out[start:end] +} diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 2b0435937..3d9b34a1f 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -2816,7 +2816,19 @@ func collectErrorHandlerStatementSpans( return } - if stmt := formatActivity(ctx, obj, entityNames, microflowNames); stmt != "" { + if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { + // The header alone is not a statement: without `begin … end loop;` + // the description did not parse (ako/mxcli#1456). + start := len(statements) + if stmt := formatActivity(ctx, obj, entityNames, microflowNames); stmt != "" { + notes(obj, indentStr) + statements = append(statements, indentStr+stmt) + } + statements = append(statements, indentStr+"begin") + emitLoopBody(ctx, loop, flowsByOrigin, flowsByDest, entityNames, microflowNames, &statements, indent, nil, 0, annotationsByTarget) + statements = append(statements, indentStr+loopEndKeyword(loop)+";") + spans = append(spans, errorHandlerSpan{id: id, start: start, end: len(statements) - 1}) + } else if stmt := formatActivity(ctx, obj, entityNames, microflowNames); stmt != "" { start := len(statements) notes(obj, indentStr) statements = append(statements, indentStr+stmt) @@ -2848,12 +2860,41 @@ func findErrorHandlerSplitMergePoints( return result } +// firstReachableErrorHandlerMerge finds where the handler starting at startID +// joins something else: the first merge with a way in from outside the +// handler. A merge every way into which lies inside the handler — the one +// closing an `if` in the handler body — joins nothing but the handler's own +// branches, and stopping there dropped everything after `end if;` (ako/mxcli#1456). +// A merge with one way in is the degenerate case of the same rule. func firstReachableErrorHandlerMerge( startID model.ID, activityMap map[model.ID]microflows.MicroflowObject, flowsByOrigin map[model.ID][]*microflows.SequenceFlow, incoming map[model.ID]int, ) model.ID { + inHandler := make(map[model.ID]bool) + for queue := []model.ID{startID}; len(queue) > 0; queue = queue[1:] { + id := queue[0] + if id == "" || inHandler[id] { + continue + } + inHandler[id] = true + for _, flow := range findNormalFlows(flowsByOrigin[id]) { + queue = append(queue, flow.DestinationID) + } + } + joinsOutside := make(map[model.ID]bool) + for originID, flows := range flowsByOrigin { + if inHandler[originID] { + continue + } + for _, f := range flows { + if f != nil { + joinsOutside[f.DestinationID] = true + } + } + } + visited := make(map[model.ID]bool) queue := []model.ID{startID} for len(queue) > 0 { @@ -2863,7 +2904,7 @@ func firstReachableErrorHandlerMerge( continue } visited[id] = true - if _, isMerge := activityMap[id].(*microflows.ExclusiveMerge); isMerge && !isNoOpMerge(id, activityMap, incoming) { + if _, isMerge := activityMap[id].(*microflows.ExclusiveMerge); isMerge && !isNoOpMerge(id, activityMap, incoming) && joinsOutside[id] { return id } for _, flow := range findNormalFlows(flowsByOrigin[id]) { From 3a9da86525b2978714de1bb221f6944397df6c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:54:36 +0000 Subject: [PATCH 09/22] fix: scheduled_events() empty module_name and excludeModules leak for events in folders An event's container is a folder whenever it is not at the module root, which is how Marketplace modules ship theirs. The iterator mapped the direct container to a module, so module_name was "", qualified_name became ".Name", and IsExcluded("") let excluded modules through. Walk the folder chain to the module, as the catalog's hierarchy does. The test helper closed the catalog before returning it, so the module lookup failed silently in every existing test; it now closes on cleanup. Fixes #1452 (mendixlabs/mxcli#1555) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FiK7bYZEqF75x9DeX6Eyrm --- ...cheduled-events-in-folder-lose-module.json | 1 + .../1452-lint-scheduled-event-in-folder.mdl | 32 ++++++++++++++ mdl/linter/context.go | 27 +++++++++++- mdl/linter/starlark_scheduledevents_test.go | 42 ++++++++++++++++++- 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-17-scheduled-events-in-folder-lose-module.json create mode 100644 mdl-examples/bug-tests/1452-lint-scheduled-event-in-folder.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-scheduled-events-in-folder-lose-module.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-scheduled-events-in-folder-lose-module.json new file mode 100644 index 000000000..27f19c18d --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-scheduled-events-in-folder-lose-module.json @@ -0,0 +1 @@ +{"area": "mdl/linter", "date": "2026-10-17", "symptom": "scheduled_events() returns events of modules listed in excludeModules (Marketplace modules MicrosoftGraph, OIDC), and each event has an empty module_name; qualified_name becomes .Name.", "cause": "LintContext.ScheduledEvents() is reader-backed, not catalog-backed, and mapped the event's direct ContainerID to a module name. An event inside a folder (how every Marketplace module ships them) has a folder as container, so the lookup missed, module_name was \"\", and IsExcluded(\"\") is false, so the exclusion silently did nothing.", "file": "mdl/linter/context.go (ScheduledEvents), mdl/linter/starlark_scheduledevents_test.go", "insight": "Any iterator that resolves a module from ContainerID must walk the folder chain (the catalog's hierarchy.findModuleID does); grep for ContainerID lookups against a module map. The existing tests could not see it for a second reason: their helper closed the catalog before returning it, so the module query failed silently in every test and module_name was never asserted. An empty module_name is the tell for both, and IsExcluded(\"\") turns it into an exclusion leak.", "refs": "ako/mxcli#1452, mendixlabs/mxcli#1555"} diff --git a/mdl-examples/bug-tests/1452-lint-scheduled-event-in-folder.mdl b/mdl-examples/bug-tests/1452-lint-scheduled-event-in-folder.mdl new file mode 100644 index 000000000..7113bbf06 --- /dev/null +++ b/mdl-examples/bug-tests/1452-lint-scheduled-event-in-folder.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1452 (mendixlabs/mxcli#1555) — scheduled_events() lost the module +-- of an event kept in a folder +-- ============================================================================ +-- +-- Reported: "scheduled_events() returns events of modules listed in +-- excludeModules (e.g. Marketplace modules MicrosoftGraph, OIDC), and each +-- event has an empty module_name; qualified_name becomes .Name". +-- +-- The iterator mapped the event's direct container to a module, and a +-- Marketplace module keeps its events in folders, so the container is a +-- folder: no module name, and IsExcluded("") is false. +-- +-- The assertion is a Go test (mdl/linter/starlark_scheduledevents_test.go, +-- TestScheduledEvents_InFolderResolveModule): the symptom is in the lint API, +-- which `make check-mdl` does not exercise. To reproduce by hand, run this +-- script, then lint a rule that returns +-- [violation(message = e.qualified_name) for e in scheduled_events()] +-- with and without `-e Ext`: it must print Ext.SE_Sync, and nothing when +-- excluded. +-- ============================================================================ + +create module Ext; + +create microflow Ext.SE_DoSync () begin end; + +create scheduled event Ext.SE_Sync folder 'Private/Events' ( + Microflow: Ext.SE_DoSync, + Repeat: Hourly, + Multiplier: 1 +); diff --git a/mdl/linter/context.go b/mdl/linter/context.go index a3681687f..ee4a531c9 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -1086,8 +1086,33 @@ func (ctx *LintContext) ScheduledEvents() iter.Seq[ScheduledEvent] { return } + // An event's container is a folder whenever it is not at the module + // root — every Marketplace module keeps its events that way — so walk + // up the folder chain to the module (ako/mxcli#1452). + folderParent := map[model.ID]model.ID{} + if folders, err := ctx.reader.ListFolders(); err == nil { + for _, f := range folders { + folderParent[f.ID] = f.ContainerID + } + } else { + ctx.recordQueryError("ScheduledEvents (folders)", err) + } + moduleOf := func(container model.ID) string { + for range len(folderParent) + 1 { + if name, ok := moduleNames[container]; ok { + return name + } + parent, ok := folderParent[container] + if !ok { + return "" + } + container = parent + } + return "" + } + for _, e := range events { - moduleName := moduleNames[e.ContainerID] + moduleName := moduleOf(e.ContainerID) if ctx.IsExcluded(moduleName) { continue } diff --git a/mdl/linter/starlark_scheduledevents_test.go b/mdl/linter/starlark_scheduledevents_test.go index ae1b64b68..2a9e72a78 100644 --- a/mdl/linter/starlark_scheduledevents_test.go +++ b/mdl/linter/starlark_scheduledevents_test.go @@ -21,6 +21,7 @@ import ( // everything except ListScheduledEvents, which is configurable via a func field. type minimalReader struct { listScheduledEvents func() ([]*model.ScheduledEvent, error) + folders []*types.FolderInfo } func (m *minimalReader) GetMicroflow(_ model.ID) (*microflows.Microflow, error) { @@ -32,7 +33,7 @@ func (m *minimalReader) GetProjectSettings() (*model.ProjectSettings, error) func (m *minimalReader) GetNavigation() (*types.NavigationDocument, error) { return nil, nil } func (m *minimalReader) ListPages() ([]*pages.Page, error) { return nil, nil } func (m *minimalReader) ListModules() ([]*model.Module, error) { return nil, nil } -func (m *minimalReader) ListFolders() ([]*types.FolderInfo, error) { return nil, nil } +func (m *minimalReader) ListFolders() ([]*types.FolderInfo, error) { return m.folders, nil } func (m *minimalReader) GetRawUnit(_ model.ID) (map[string]any, error) { return nil, nil } func (m *minimalReader) ListScheduledEvents() ([]*model.ScheduledEvent, error) { if m.listScheduledEvents != nil { @@ -160,7 +161,9 @@ func newSingleModuleCatalog(t *testing.T, containerID model.ID) *catalog.Catalog ); err != nil { t.Fatalf("insert module: %v", err) } - cat.Close() + // Closed on cleanup, not here: a closed catalog makes the module lookup + // fail silently, so module_name was never actually exercised. + t.Cleanup(func() { cat.Close() }) return cat } @@ -420,3 +423,38 @@ func TestStarlarkScheduledEventsBuiltin(t *testing.T) { } } } + +// TestScheduledEvents_InFolderResolveModule is ako/mxcli#1452: "scheduled_events() +// returns events of modules listed in excludeModules ..., and each event has an +// empty module_name; qualified_name becomes .Name". A Marketplace module keeps +// its events in folders, so the event's container is a folder, not the module. +func TestScheduledEvents_InFolderResolveModule(t *testing.T) { + reader := &minimalReader{ + listScheduledEvents: func() ([]*model.ScheduledEvent, error) { + return []*model.ScheduledEvent{{ContainerID: model.ID("fld-inner"), Name: "SE_Sync", Enabled: true}}, nil + }, + folders: []*types.FolderInfo{ + {ID: model.ID("fld-outer"), ContainerID: model.ID("mod-1"), Name: "Private"}, + {ID: model.ID("fld-inner"), ContainerID: model.ID("fld-outer"), Name: "Events"}, + }, + } + cat := newSingleModuleCatalog(t, model.ID("mod-1")) + + ctx := linter.NewLintContext(cat, reader) + var got []linter.ScheduledEvent + for se := range ctx.ScheduledEvents() { + got = append(got, se) + } + if len(got) != 1 { + t.Fatalf("got %d events, want 1", len(got)) + } + if got[0].ModuleName != "MyModule" || got[0].QualifiedName != "MyModule.SE_Sync" { + t.Errorf("module_name = %q, qualified_name = %q; want MyModule, MyModule.SE_Sync", + got[0].ModuleName, got[0].QualifiedName) + } + + ctx.SetExcludedModules([]string{"MyModule"}) + for se := range ctx.ScheduledEvents() { + t.Errorf("event %q of an excluded module was yielded", se.QualifiedName) + } +} From d06dd923438e08ebaeface15f54d4e6dec2f4b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:57:46 +0000 Subject: [PATCH 10/22] fix: permissions() has no rows for nanoflow EXECUTE grants buildPermissions read entities, microflows, pages and OData services but never nanoflows, so a stored GRANT EXECUTE ON NANOFLOW was invisible to every lint rule. Emit element_type NANOFLOW / access_type EXECUTE rows from the nanoflow's AllowedModuleRoles, and document the value in the write-lint-rules skill. Fixes #1434 (mendixlabs/mxcli#1536) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FiK7bYZEqF75x9DeX6Eyrm --- ...26-10-17-permissions-no-nanoflow-rows.json | 1 + .../skills/mendix/write-lint-rules/SKILL.md | 4 +- .../1434-lint-nanoflow-permissions.mdl | 32 ++++++++ mdl/catalog/builder_permissions.go | 36 ++++++++- .../builder_permissions_nanoflow_test.go | 76 +++++++++++++++++++ mdl/linter/context.go | 2 +- 6 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-17-permissions-no-nanoflow-rows.json create mode 100644 mdl-examples/bug-tests/1434-lint-nanoflow-permissions.mdl create mode 100644 mdl/catalog/builder_permissions_nanoflow_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-permissions-no-nanoflow-rows.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-permissions-no-nanoflow-rows.json new file mode 100644 index 000000000..b887f2c60 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-permissions-no-nanoflow-rows.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-10-17", "symptom": "GRANT EXECUTE ON NANOFLOW Module.NF TO Module.Role is stored, but permissions() only returns microflow EXECUTE rows; a lint rule over exposed flows misses every exposed nanoflow.", "cause": "buildPermissions had per-document builders for entities, microflows, pages and OData services and none for nanoflows, although microflows.Nanoflow carries AllowedModuleRoles exactly like a microflow.", "file": "mdl/catalog/builder_permissions.go (buildNanoflowPermissions), mdl/catalog/builder_permissions_nanoflow_test.go", "insight": "The catalog-vs-rule vocabulary test (lint_rule_doc_vocabulary_test) only checks documented ⊆ emitted, so an element type the catalog never emits is invisible to it; add the constant to PermissionElementTypes and the skill row by hand. When verifying with a real lint run, delete /.mxcli/catalog.db between the fixed and unfixed binaries: lint reuses the cached catalog, and the unfixed binary then reports the fixed rows.", "refs": "ako/mxcli#1434, mendixlabs/mxcli#1536"} diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index f0cf7cbeb..2f88d2574 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -521,11 +521,11 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | Property | Type | Example | |----------|------|---------| | `module_role_name` | string | `"Admin"` | -| `element_type` | string | `"ENTITY"`, `"MICROFLOW"`, `"PAGE"`, `"ODATA_SERVICE"` (from `permissions()` only) | +| `element_type` | string | `"ENTITY"`, `"MICROFLOW"`, `"NANOFLOW"`, `"PAGE"`, `"ODATA_SERVICE"` (from `permissions()` only) | | `element_name` | string | `"Sales.Customer"` | | `module_name` | string | `"Sales"` | | `entity_name` | string | `"Sales.Customer"` (from `permissions_for()` only) | -| `access_type` | string | `"CREATE"`, `"READ"`, `"WRITE"`, `"DELETE"` (entity), `"EXECUTE"` (microflow), `"VIEW"` (page), `"ACCESS"` (OData service), `"MEMBER_READ"`, `"MEMBER_WRITE"` | +| `access_type` | string | `"CREATE"`, `"READ"`, `"WRITE"`, `"DELETE"` (entity), `"EXECUTE"` (microflow, nanoflow), `"VIEW"` (page), `"ACCESS"` (OData service), `"MEMBER_READ"`, `"MEMBER_WRITE"` | | `member_name` | string | Attribute name (for MEMBER_READ/MEMBER_WRITE) | | `xpath_constraint` | string | XPath constraint or empty | | `is_constrained` | bool | True if XPath constraint is set | diff --git a/mdl-examples/bug-tests/1434-lint-nanoflow-permissions.mdl b/mdl-examples/bug-tests/1434-lint-nanoflow-permissions.mdl new file mode 100644 index 000000000..0c8a7ed53 --- /dev/null +++ b/mdl-examples/bug-tests/1434-lint-nanoflow-permissions.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1434 (mendixlabs/mxcli#1536) — permissions() had no rows for +-- nanoflows +-- ============================================================================ +-- +-- Reported: "GRANT EXECUTE ON NANOFLOW Module.NF TO Module.Role is stored, but +-- permissions() only returns microflow EXECUTE rows. A rule that checks exposed +-- flows ... misses every exposed nanoflow." +-- +-- The catalog's buildPermissions read microflows, pages, entities and OData +-- services, never nanoflows. It now emits element_type "NANOFLOW", +-- access_type "EXECUTE". +-- +-- The assertion is a Go test (mdl/catalog/builder_permissions_nanoflow_test.go): +-- the symptom is in the catalog, which `make check-mdl` does not exercise. To +-- reproduce by hand, run this script, then lint a rule returning +-- [violation(message = p.element_name) for p in permissions() +-- if p.element_type == "NANOFLOW"] +-- It must report Perm.NF_Open (and only that, NF_Private being granted to no +-- one). +-- ============================================================================ + +create module Perm; + +create module role Perm.User; + +create nanoflow Perm.NF_Open () begin end; + +create nanoflow Perm.NF_Private () begin end; + +grant execute on nanoflow Perm.NF_Open to Perm.User; diff --git a/mdl/catalog/builder_permissions.go b/mdl/catalog/builder_permissions.go index 6ac3dd7d4..6e0dc8d90 100644 --- a/mdl/catalog/builder_permissions.go +++ b/mdl/catalog/builder_permissions.go @@ -16,6 +16,7 @@ import ( const ( PermissionElementEntity = "ENTITY" PermissionElementMicroflow = "MICROFLOW" + PermissionElementNanoflow = "NANOFLOW" PermissionElementPage = "PAGE" PermissionElementODataService = "ODATA_SERVICE" @@ -23,7 +24,7 @@ const ( AccessTypeRead = "READ" AccessTypeWrite = "WRITE" AccessTypeDelete = "DELETE" - AccessTypeExecute = "EXECUTE" // microflow + AccessTypeExecute = "EXECUTE" // microflow, nanoflow AccessTypeView = "VIEW" // page AccessTypeAccess = "ACCESS" // published OData service AccessTypeMemberRead = "MEMBER_READ" @@ -36,6 +37,7 @@ var ( PermissionElementTypes = []string{ PermissionElementEntity, PermissionElementMicroflow, + PermissionElementNanoflow, PermissionElementPage, PermissionElementODataService, } @@ -75,6 +77,7 @@ func (b *Builder) buildPermissions() error { permCount += b.buildEntityPermissions(stmt, projectID, snapshotID) permCount += b.buildMicroflowPermissions(stmt, projectID, snapshotID) + permCount += b.buildNanoflowPermissions(stmt, projectID, snapshotID) permCount += b.buildPagePermissions(stmt, projectID, snapshotID) permCount += b.buildODataServicePermissions(stmt, projectID, snapshotID) @@ -247,6 +250,37 @@ func (b *Builder) buildMicroflowPermissions(stmt *sql.Stmt, projectID, snapshotI return count } +// buildNanoflowPermissions extracts nanoflow execute permissions. A nanoflow +// carries AllowedModuleRoles exactly like a microflow, and a rule checking +// exposed flows misses every one of them without these rows (ako/mxcli#1434). +func (b *Builder) buildNanoflowPermissions(stmt *sql.Stmt, projectID, snapshotID string) int { + count := 0 + + nfs, err := b.cachedNanoflows() + if err != nil { + return 0 + } + + for _, nf := range nfs { + if len(nf.AllowedModuleRoles) == 0 { + continue + } + + moduleID := b.hierarchy.findModuleID(nf.ContainerID) + moduleName := b.hierarchy.getModuleName(moduleID) + nfQN := moduleName + "." + nf.Name + + for _, roleID := range nf.AllowedModuleRoles { + // AllowedModuleRoles are BY_NAME strings stored as model.ID + roleName := string(roleID) + stmt.Exec(roleName, PermissionElementNanoflow, nfQN, nil, AccessTypeExecute, nil, nil, moduleName, projectID, snapshotID) + count++ + } + } + + return count +} + // buildPagePermissions extracts page view permissions. func (b *Builder) buildPagePermissions(stmt *sql.Stmt, projectID, snapshotID string) int { count := 0 diff --git a/mdl/catalog/builder_permissions_nanoflow_test.go b/mdl/catalog/builder_permissions_nanoflow_test.go new file mode 100644 index 000000000..27890377c --- /dev/null +++ b/mdl/catalog/builder_permissions_nanoflow_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "sort" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// "GRANT EXECUTE ON NANOFLOW Module.NF TO Module.Role is stored, but +// permissions() only returns microflow EXECUTE rows" (ako/mxcli#1434). The +// granted microflow is the control: it had a row before the fix too. +func TestPermissionsIncludeNanoflowExecute(t *testing.T) { + const mod = model.ID("mod-t") + be := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return &model.ProjectSettings{}, nil }, + ListModuleSettingsFunc: func() ([]*types.ModuleSettings, error) { return nil, nil }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{{BaseElement: model.BaseElement{ID: mod}, Name: "T"}}, nil + }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { return nil, nil }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { return &types.NavigationDocument{}, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{{ + BaseElement: model.BaseElement{ID: "mf-1"}, ContainerID: mod, Name: "ACT_Save", + AllowedModuleRoles: []model.ID{"T.User"}, + }}, nil + }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { + return []*microflows.Nanoflow{ + {BaseElement: model.BaseElement{ID: "nf-1"}, ContainerID: mod, Name: "NF_Open", + AllowedModuleRoles: []model.ID{"T.User", "T.Admin"}}, + // Not granted to anyone: no row. + {BaseElement: model.BaseElement{ID: "nf-2"}, ContainerID: mod, Name: "NF_Private"}, + }, nil + }, + } + cat, err := New() + if err != nil { + t.Fatalf("catalog.New: %v", err) + } + t.Cleanup(func() { cat.Close() }) + b := NewBuilder(cat, be) + b.SetFullMode(true) + if err := b.Build(nil); err != nil { + t.Fatalf("catalog build: %v", err) + } + + var got []string + for _, row := range queryRows(t, cat, `SELECT ElementType, ElementName, AccessType, ModuleRoleName, ModuleName + FROM permissions WHERE AccessType = 'EXECUTE'`) { + got = append(got, row[0].(string)+" "+row[1].(string)+" "+row[2].(string)+" "+row[3].(string)+" "+row[4].(string)) + } + sort.Strings(got) + want := []string{ + "MICROFLOW T.ACT_Save EXECUTE T.User T", + "NANOFLOW T.NF_Open EXECUTE T.Admin T", + "NANOFLOW T.NF_Open EXECUTE T.User T", + } + if len(got) != len(want) { + t.Fatalf("EXECUTE rows:\n%v\nwant:\n%v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("EXECUTE rows:\n%v\nwant:\n%v", got, want) + } + } +} diff --git a/mdl/linter/context.go b/mdl/linter/context.go index a3681687f..9f5771ee7 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -434,7 +434,7 @@ func (ctx *LintContext) PermissionsFor(entityQualifiedName string) iter.Seq[Perm // AllPermission represents a permission from the catalog covering all element types. type AllPermission struct { ModuleRoleName string - ElementType string // ENTITY, MICROFLOW, PAGE, ODATA_SERVICE + ElementType string // ENTITY, MICROFLOW, NANOFLOW, PAGE, ODATA_SERVICE ElementName string // qualified name of the element MemberName string // populated for MEMBER_READ/MEMBER_WRITE AccessType string // CREATE, READ, WRITE, DELETE, EXECUTE, VIEW, ACCESS, MEMBER_READ, MEMBER_WRITE From 14a01a95f8261167b212a2bd71f6759581f92041 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:09:17 +0000 Subject: [PATCH 11/22] fix(executor): refuse a statement that can never run (mendixlabs/mxcli#1491) A statement after `return` in the same block, or after a `while true` that has no `break`, passed check, exec wrote it, and mx check refused the flow. New rule MDL-FLOW05 (error) refuses the first dead statement of each block after return, raise error, break, continue, join, an if/else that ends on both sides, or a `while true` with no break of its own. A `merge` makes the path live again. Runs for microflows and nanoflows; stands down for an @excluded document like the other #893 gap rules. Measured on mxbuild 11.13.0, one microflow per shape (mx check -j for the per-document attribution): log after return in an if CE0709 log after break / continue CE0709 log after raise error / return in a handler CE0105 + CE0709 log after join CE0105 + CE0709 log after if/else returning both CE0104 + CE0105 log after while true (no break) CE0104 + CE0105 return after that loop CE0104 return after loop whose only break is in a nested loop CE0104 nanoflow, log after return in an if CE0709 Controls, 0 errors: nothing after the loop, break then return, an if without else that returns, merge after a return reached by a join. Splits are not treated as ending the path (an all-returning enum split is CE0104 but with CE0079, whose fix is the empty outcome). Control: with the rule unwired, the test reports 0 findings on all nine dead shapes. The cheatsheet listed CE0104 for code after a return in an if; that shape is CE0709. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HJcUHPvAr7SDBcAoGHYcPQ --- ...urn-or-while-true-passes-check-ce0104.json | 1 + .../skills/mendix/cheatsheet-errors/SKILL.md | 22 +- .../1491-unreachable-statement-controls.mdl | 58 +++++ .../1491-unreachable-statement.fail.mdl | 27 +++ mdl/executor/validate_microflow.go | 4 + .../validate_microflow_unreachable.go | 194 +++++++++++++++ .../validate_microflow_unreachable_test.go | 220 ++++++++++++++++++ mdl/executor/validate_nanoflow.go | 3 + 8 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-statement-after-return-or-while-true-passes-check-ce0104.json create mode 100644 mdl-examples/bug-tests/1491-unreachable-statement-controls.mdl create mode 100644 mdl-examples/bug-tests/1491-unreachable-statement.fail.mdl create mode 100644 mdl/executor/validate_microflow_unreachable.go create mode 100644 mdl/executor/validate_microflow_unreachable_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-statement-after-return-or-while-true-passes-check-ce0104.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-statement-after-return-or-while-true-passes-check-ce0104.json new file mode 100644 index 000000000..2d5ae7017 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-statement-after-return-or-while-true-passes-check-ce0104.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-17","symptom":"A statement after `return` (or break/continue/join/raise error, an if/else returning on both sides, or a `while true` with no break) passed `check`, exec wrote it, and mx check refused the flow with CE0104, CE0105 or CE0709","cause":"No check modelled reachability; the builder wires the dead statement out of an End/Break/Continue/Error event or a merge-bound path anyway","file":"mdl/executor/validate_microflow_unreachable.go","insight":"The CE code depends on what the dead statement hangs off, not on the cause: right after a return inside an if it is CE0709 (the flow leaves an end event), after a loop or if/else it is CE0104 (+CE0105 if last) — so the cheatsheet's 'CE0104 = code after RETURN' sent readers to the wrong code for the commonest shape. mx check's console output carries no document name; `mx check -j out.json` does (locations[].document-name), which is what makes one project with N shapes one 25 s measurement instead of N. A `merge` restarts reachability (a join can land there), and an enum split whose cases all return does make the next statement CE0104 but alongside CE0079 — the fix there is the empty outcome, so it is deliberately not reported as dead code. A break inside a case or handler body of a `while true` builds as something else entirely (CE0068 / CE0644), so the break search is widened only in the direction that suppresses a report.","refs":["mendixlabs/mxcli#1491","ako/mxcli#1347"],"ce":["CE0104","CE0105","CE0709"],"rules":["MDL-FLOW05"]} diff --git a/.claude/skills/mendix/cheatsheet-errors/SKILL.md b/.claude/skills/mendix/cheatsheet-errors/SKILL.md index f9087ab86..7363e6266 100644 --- a/.claude/skills/mendix/cheatsheet-errors/SKILL.md +++ b/.claude/skills/mendix/cheatsheet-errors/SKILL.md @@ -12,11 +12,11 @@ description: "Quick fixes for the MDL syntax errors that come up most: undeclare |---|---| | 26-78 | Variable Errors | | 79-130 | Expression Errors | -| 131-178 | Control Flow Errors | -| 179-216 | Syntax Errors | -| 217-281 | Reference Errors | -| 282-297 | Studio Pro Error Code Reference | -| 298-309 | Quick Validation Checklist | +| 131-184 | Control Flow Errors | +| 185-222 | Syntax Errors | +| 223-287 | Reference Errors | +| 288-303 | Studio Pro Error Code Reference | +| 304-315 | Quick Validation Checklist | # MDL Common Errors Cheatsheet @@ -154,9 +154,15 @@ begin end; ``` -### "Action activity is unreachable" (CE0104) +### "Action activity is unreachable" (CE0104), or CE0709 / CE0105 -**Problem**: Code after RETURN statement. +**Problem**: A statement that can never run — after `return`, `raise error`, +`break`, `continue` or `join` in the same block, after an `if`/`else` that +returns on both sides, or after a `while true` with no `break`. The code +depends on where the dead statement hangs: right after a `return` inside an +`if` it is **CE0709** "Sequence flow is not accepted by origin or destination", +after a loop or an `if`/`else` it is CE0104 (plus CE0105 if it is the last +statement). `mxcli check` refuses all of them as **MDL-FLOW05**. ```mdl -- WRONG: Code after RETURN @@ -284,7 +290,7 @@ Run with `-p` for the fullest coverage. | Code | Message | Common Cause | |------|---------|--------------| | CE0053 | Selected type is not allowed | Declared an object/list variable — get it from a parameter/retrieve/create/loop (MDL043 objects, MDL040 lists) | -| CE0104 | Action activity is unreachable | Code after RETURN | +| CE0104 / CE0709 | Activity is unreachable / Sequence flow is not accepted | Code after RETURN, BREAK, CONTINUE, JOIN, or a `while true` with no BREAK — `check` flags MDL-FLOW05 | | CE0105 | Must end with end event | Missing RETURN | | CE0117 | Error in expression | Unqualified association path | | CE0117 | …on a button inside a data container | The button passes the container it sits in by its widget name (`$dvGate` inside `dvGate`). That name is a variable only for containers nested below it; use `$currentObject` there. Same for data-source arguments, `Visible:`, `Editable:`, `DynamicClasses:`, and (as CE0161) a nested list's XPath `where`. MDL-BUTTON02 | diff --git a/mdl-examples/bug-tests/1491-unreachable-statement-controls.mdl b/mdl-examples/bug-tests/1491-unreachable-statement-controls.mdl new file mode 100644 index 000000000..ff6b85e35 --- /dev/null +++ b/mdl-examples/bug-tests/1491-unreachable-statement-controls.mdl @@ -0,0 +1,58 @@ +mdl 1; +-- Controls for mendixlabs/mxcli#1491 (MDL-FLOW05): each shape is close to one +-- the rule refuses, and each builds with 0 errors on mxbuild 11.13.0. `check` +-- must pass all of them, or the rule refuses a script that builds. +create module if not exists Bug1491; + +create or modify microflow Bug1491.Sub ($In: Integer) returns Boolean +begin + return true; +end; + +-- Nothing after a `while true` left only by return. +create or modify microflow Bug1491.NothingAfterWhileTrue ($N: Integer) +begin + declare $I Integer = 0; + while true + begin + set $I = $I + 1; + if $I >= $N then + return; + end if; + end while; +end; + +-- The loop is left by `break`, so the return after it runs. +create or modify microflow Bug1491.BreakThenReturn ($N: Integer) +begin + declare $I Integer = 0; + while true + begin + set $I = $I + 1; + if $I >= $N then + break; + end if; + end while; + return; +end; + +-- An `if` without `else` that returns: the code after it runs on the other path. +create or modify microflow Bug1491.IfWithoutElse ($V: Integer) +begin + if $V < 0 then + return; + end if; + log info node 'Bug1491' 'reachable'; +end; + +-- A `merge` after a `return` is reached by the `join`, so the path is live again. +create or modify microflow Bug1491.MergeAfterReturn () returns Boolean +begin + $X = call microflow Bug1491.Sub(In = 1) on error without rollback begin + join on_failure; + end error; + return true; + merge on_failure; + log error node 'Bug1491' 'the call failed'; + return false; +end; diff --git a/mdl-examples/bug-tests/1491-unreachable-statement.fail.mdl b/mdl-examples/bug-tests/1491-unreachable-statement.fail.mdl new file mode 100644 index 000000000..f3ca2f129 --- /dev/null +++ b/mdl-examples/bug-tests/1491-unreachable-statement.fail.mdl @@ -0,0 +1,27 @@ +mdl 1; +-- Bug (mendixlabs/mxcli#1491): a statement that can never run passed `check`, +-- `exec` wrote it, and `mx check` refused the microflow. Here: a `return` +-- after a `while true` that is left only by `return`. +-- +-- Measured on mxbuild 11.13.0 (reported on 10.24.18 with the same codes): +-- this microflow -> CE0104 "End event is unreachable." +-- a log after the same loop -> CE0104 + CE0105 +-- a log after `return` in an if -> CE0709 "Sequence flow is not accepted …" +-- a log after break / continue / join / raise error -> CE0709 (+ CE0105) +-- The controls, all 0 errors, are in 1491-unreachable-statement-controls.mdl. +-- +-- `check` now refuses this as MDL-FLOW05; no project is needed to decide it. +create module if not exists Bug1491; + +create or modify microflow Bug1491.ReturnAfterWhileTrue ($N: Integer) +begin + declare $I Integer = 0; + while true + begin + set $I = $I + 1; + if $I >= $N then + return; + end if; + end while; + return; +end; diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 0c3984e43..f0592091c 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -182,6 +182,10 @@ func (v *microflowValidator) validate(body []ast.MicroflowStatement) { // CONV011 all along, but only after the write and only project-wide. See // validate_commit_in_loop.go. v.checkCommitInLoop(body) + + // mendixlabs/mxcli#1491: a statement after one that ends the path is + // written anyway and refused by the build. See validate_microflow_unreachable.go. + v.checkUnreachableStatements(body) } // checkDuplicateLoopVariables flags a loop iterator name used by more than one diff --git a/mdl/executor/validate_microflow_unreachable.go b/mdl/executor/validate_microflow_unreachable.go new file mode 100644 index 000000000..dcf22c644 --- /dev/null +++ b/mdl/executor/validate_microflow_unreachable.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// checkUnreachableStatements (MDL-FLOW05) refuses a statement that can never run +// because the statement before it in the same block ends the path: `return`, +// `raise error`, `break`, `continue`, `join`, an `if` whose two branches both +// end, or a `while true` with no `break` of its own (mendixlabs/mxcli#1491). +// +// The builder writes such a statement anyway, wired out of an End event, a +// Break/Continue/Error event or a merge-bound path, and the build refuses the +// result. Measured on mxbuild 11.13.0, one microflow per shape, the error code +// depending only on what the dead statement hangs off: +// +// log after `return` in an if CE0709 +// log after `break` / `continue` CE0709 +// log after `raise error` / `return` in an error handler CE0105 + CE0709 +// log after `join` CE0105 + CE0709 x3 +// log after an if/else that returns on both sides CE0104 + CE0105 +// log after a `while true` left only by return CE0104 + CE0105 +// `return` after that same loop CE0104 "End event is unreachable." +// +// and the controls, all 0 errors: the same loop with nothing after it, a +// `while true` with a `break` and a `return` after it, an if WITHOUT else that +// returns followed by code, and a `merge` after a `return` that a `join` +// reaches. That last one is why a `merge` restarts reachability: it is the one +// statement a path can arrive at from elsewhere. +// +// Also measured: a `while true` whose only break is in a NESTED loop is the +// same CE0104 on the `return` after it, and so is a nanoflow (CE0709). +// +// Splits are deliberately not treated as ending the path even when every case +// does. An enumeration split whose cases all return does make the next +// statement CE0104 — but mxbuild reports CE0079 for the missing (empty) outcome +// with it, and the fix is that outcome, not deleting the statement; telling the +// author the code is dead would send them the wrong way. Missing a dead +// statement costs a later build error; claiming one that is live would refuse a +// script that builds, which is the expensive direction. +// +// Only the first dead statement of a block is reported — the rest of the block +// is dead for the same reason, and one message names the fix. +func (v *microflowValidator) checkUnreachableStatements(body []ast.MicroflowStatement) { + if v.skipCEGapRules() { + return + } + v.walkUnreachable(body) +} + +// walkUnreachable reports the first dead statement of stmts and of every block +// nested in it, and returns whether stmts as a whole ends the path. +func (v *microflowValidator) walkUnreachable(stmts []ast.MicroflowStatement) bool { + ended := false + var after ast.MicroflowStatement + reported := false + for _, s := range stmts { + if _, ok := s.(*ast.MergeStmt); ok { + // A join elsewhere can arrive here, so the path is live again. + ended, reported = false, false + continue + } + if ended && !reported { + reported = true + v.addViolation("MDL-FLOW05", linter.SeverityError, + "this "+statementKindWord(s)+" can never run: in the same block it follows "+ + endsPathDescription(after)+". mxbuild refuses the flow (CE0104 unreachable / CE0105 / CE0709)", + unreachableSuggestion(after)) + } + if v.statementEndsPath(s) { + ended, after = true, s + } + } + return ended +} + +// statementEndsPath reports whether no path continues past s to the statement +// after it, walking the blocks inside s for their own dead statements on the way. +func (v *microflowValidator) statementEndsPath(s ast.MicroflowStatement) bool { + if eh := stmtErrorHandling(s); eh != nil && len(eh.Body) > 0 { + v.walkUnreachable(eh.Body) + } + switch st := s.(type) { + case *ast.ReturnStmt, *ast.RaiseErrorStmt, *ast.BreakStmt, *ast.ContinueStmt, *ast.JoinStmt: + return true + case *ast.IfStmt: + thenEnds := v.walkUnreachable(st.ThenBody) + elseEnds := v.walkUnreachable(st.ElseBody) + return len(st.ElseBody) > 0 && thenEnds && elseEnds + case *ast.WhileStmt: + v.walkUnreachable(st.Body) + return isUnconditionalTrueWhile(st) && !breaksOutOfLoop(st.Body) + case *ast.LoopStmt: + v.walkUnreachable(st.Body) + case *ast.EnumSplitStmt: + for _, c := range st.Cases { + v.walkUnreachable(c.Body) + } + v.walkUnreachable(st.ElseBody) + case *ast.InheritanceSplitStmt: + for _, c := range st.Cases { + v.walkUnreachable(c.Body) + } + v.walkUnreachable(st.ElseBody) + } + return false +} + +// breaksOutOfLoop reports whether stmts holds a `break` that leaves the loop +// whose body it is. It looks into every nested block except another loop's body, +// where a break leaves that loop instead. It is deliberately wider than +// containsBreakForCurrentLoop, which does not look into splits or handlers: a +// break found here only stops a report, so seeing too many is the safe side. +func breaksOutOfLoop(stmts []ast.MicroflowStatement) bool { + for _, s := range stmts { + if eh := stmtErrorHandling(s); eh != nil && breaksOutOfLoop(eh.Body) { + return true + } + switch st := s.(type) { + case *ast.BreakStmt: + return true + case *ast.IfStmt: + if breaksOutOfLoop(st.ThenBody) || breaksOutOfLoop(st.ElseBody) { + return true + } + case *ast.EnumSplitStmt: + for _, c := range st.Cases { + if breaksOutOfLoop(c.Body) { + return true + } + } + if breaksOutOfLoop(st.ElseBody) { + return true + } + case *ast.InheritanceSplitStmt: + for _, c := range st.Cases { + if breaksOutOfLoop(c.Body) { + return true + } + } + if breaksOutOfLoop(st.ElseBody) { + return true + } + } + } + return false +} + +// endsPathDescription names the statement a dead one follows, for the message. +func endsPathDescription(s ast.MicroflowStatement) string { + switch s.(type) { + case *ast.IfStmt: + return "an IF whose THEN and ELSE both end the flow" + case *ast.WhileStmt: + return "a `while true` that has no BREAK, so it is left only by return or an error" + } + return "a " + statementKindWord(s) +} + +// unreachableSuggestion names the fix for a statement dead after s. +func unreachableSuggestion(s ast.MicroflowStatement) string { + if _, ok := s.(*ast.WhileStmt); ok { + return "Leave the loop with BREAK and keep the statement after END WHILE, or remove it" + } + return "Remove the statement, or move it before the " + statementKindWord(s) +} + +// statementKindWord is a short MDL name for a statement kind, for messages. +func statementKindWord(s ast.MicroflowStatement) string { + switch s.(type) { + case *ast.ReturnStmt: + return "RETURN" + case *ast.RaiseErrorStmt: + return "RAISE ERROR" + case *ast.BreakStmt: + return "BREAK" + case *ast.ContinueStmt: + return "CONTINUE" + case *ast.JoinStmt: + return "JOIN" + case *ast.IfStmt: + return "IF" + case *ast.WhileStmt: + return "WHILE" + case *ast.LoopStmt: + return "LOOP" + case *ast.LogStmt: + return "LOG" + } + return "statement" +} diff --git a/mdl/executor/validate_microflow_unreachable_test.go b/mdl/executor/validate_microflow_unreachable_test.go new file mode 100644 index 000000000..334238992 --- /dev/null +++ b/mdl/executor/validate_microflow_unreachable_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// unreachableViolations parses src and returns the MDL-FLOW05 messages per +// microflow or nanoflow name. +func unreachableViolations(t *testing.T, src string) map[string][]string { + t.Helper() + got := map[string][]string{} + for _, stmt := range parseMDL(t, src).Statements { + switch s := stmt.(type) { + case *ast.CreateMicroflowStmt: + for _, v := range ValidateMicroflow(s) { + if v.RuleID == "MDL-FLOW05" { + got[s.Name.Name] = append(got[s.Name.Name], v.Message) + } + } + case *ast.CreateNanoflowStmt: + for _, v := range ValidateNanoflow(s) { + if v.RuleID == "MDL-FLOW05" { + got[s.Name.Name] = append(got[s.Name.Name], v.Message) + } + } + } + } + return got +} + +// The shapes of mendixlabs/mxcli#1491 plus the siblings measured with it, each +// one microflow. Every "dead" one fails mxbuild 11.13.0 (CE0104 / CE0105 / +// CE0709); every "live" one builds with 0 errors. +const unreachableShapes = ` +create module Repro; +create microflow Repro.T1 ($N: Integer) begin + declare $I Integer = 0; + while true begin + set $I = $I + 1; + if $I >= $N then return; end if; + end while; + return; +end; +create microflow Repro.B ($N: Integer) begin + declare $I Integer = 0; + while true begin + set $I = $I + 1; + if $I >= $N then return; end if; + end while; + log info node 'Repro' 'after'; +end; +create microflow Repro.A ($V: Integer) begin + if $V < 0 then + return; + log info node 'Repro' 'negative'; + end if; + return; +end; +create microflow Repro.D ($N: Integer) begin + declare $I Integer = 0; + while $I < $N begin + set $I = $I + 1; + break; + log info node 'Repro' 'after break'; + end while; +end; +create microflow Repro.E ($N: Integer) begin + declare $I Integer = 0; + while $I < $N begin + set $I = $I + 1; + continue; + log info node 'Repro' 'after continue'; + end while; +end; +create microflow Repro.F ($V: Integer) begin + if $V < 0 then return; else return; end if; + log info node 'Repro' 'after'; +end; +create microflow Repro.H () returns Boolean begin + $X = call microflow Repro.Sub(In = 1) on error without rollback begin + join fail; + log info node 'Repro' 'after join'; + end error; + return true; + merge fail; + return false; +end; +create microflow Repro.I () returns Boolean begin + $X = call microflow Repro.Sub(In = 1) on error begin + raise error; + log info node 'Repro' 'after raise'; + end error; + return true; +end; +create microflow Repro.J () returns Boolean begin + $X = call microflow Repro.Sub(In = 1) on error without rollback begin + return false; + log info node 'Repro' 'after return'; + end error; + return true; +end; +create microflow Repro.C1 ($N: Integer) begin + declare $I Integer = 0; + while true begin + set $I = $I + 1; + if $I >= $N then return; end if; + end while; +end; +create microflow Repro.C2 ($N: Integer) begin + declare $I Integer = 0; + while true begin + set $I = $I + 1; + if $I >= $N then break; end if; + end while; + return; +end; +create microflow Repro.G () returns Boolean begin + $X = call microflow Repro.Sub(In = 1) on error without rollback begin + join fail; + end error; + return true; + merge fail; + log error node 'Repro' 'failed'; + return false; +end; +create microflow Repro.K ($V: Integer) begin + if $V < 0 then return; end if; + log info node 'Repro' 'reachable'; +end; +` + +func TestUnreachableStatementsAreRefused(t *testing.T) { + got := unreachableViolations(t, unreachableShapes) + for _, name := range []string{"T1", "B", "A", "D", "E", "F", "H", "I", "J"} { + if len(got[name]) != 1 { + t.Errorf("%s: got %d MDL-FLOW05 findings, want 1 (mxbuild refuses it): %v", name, len(got[name]), got[name]) + } + } + // CONTROLS: each builds with 0 errors on mxbuild 11.13.0. + for _, name := range []string{"C1", "C2", "G", "K"} { + if len(got[name]) != 0 { + t.Errorf("%s builds clean but was refused: %v", name, got[name]) + } + } + if msg := strings.Join(got["T1"], ""); !strings.Contains(msg, "while true") { + t.Errorf("T1's message should name the loop that ends the path:\n%s", msg) + } +} + +// A break that leaves the loop from inside a split or an error handler still +// makes the code after a `while true` reachable. Missing it would refuse a +// script that builds. +func TestUnreachableSeesBreakInsideSplitAndHandler(t *testing.T) { + got := unreachableViolations(t, ` +create module Repro; +create enumeration Repro.Color (Red 'Red', Blue 'Blue'); +create microflow Repro.InCase ($C: Repro.Color) begin + while true begin + case $C + when Red then break; + when Blue then return; + end case; + end while; + log info node 'Repro' 'after'; +end; +create microflow Repro.InHandler () returns Boolean begin + while true begin + $X = call microflow Repro.Sub(In = 1) on error without rollback begin + break; + end error; + end while; + return true; +end; +`) + if len(got) != 0 { + t.Errorf("a break out of the loop was missed: %v", got) + } +} + +// A break in a NESTED loop leaves that loop, not the `while true` around it. +func TestUnreachableIgnoresBreakOfNestedLoop(t *testing.T) { + got := unreachableViolations(t, ` +create module Repro; +create microflow Repro.Nested ($N: Integer) begin + declare $I Integer = 0; + while true begin + while $I < $N begin + set $I = $I + 1; + break; + end while; + if $I >= $N then return; end if; + end while; + return; +end; +`) + if len(got["Nested"]) != 1 { + t.Errorf("got %v, want one finding: the inner break does not leave the outer loop", got["Nested"]) + } +} + +// A nanoflow is built by the same flow builder. +func TestUnreachableInNanoflow(t *testing.T) { + got := unreachableViolations(t, ` +create module Repro; +create nanoflow Repro.NF ($V: Integer) begin + if $V < 0 then + return; + log info node 'Repro' 'negative'; + end if; +end; +`) + if len(got["NF"]) != 1 { + t.Errorf("got %v, want one finding", got["NF"]) + } +} diff --git a/mdl/executor/validate_nanoflow.go b/mdl/executor/validate_nanoflow.go index aa632dc3d..0c2e1440d 100644 --- a/mdl/executor/validate_nanoflow.go +++ b/mdl/executor/validate_nanoflow.go @@ -53,6 +53,9 @@ func validateNanoflowWith(stmt *ast.CreateNanoflowStmt, voids *voidCodeActions) v.checkVoidCallOutputUse(v.params, stmt.Body) // MDL-SET01: a nanoflow's Change variable is as primitive-only (CE7247). v.checkSetOnObjectVariable(v.params, stmt.Body) + // MDL-FLOW05: a nanoflow's flow is built by the same builder, so a statement + // after a return is dead there too (mendixlabs/mxcli#1491). + v.checkUnreachableStatements(stmt.Body) // The nanoflow restrictions exec's build refuses (validateNanoflow): an // action a nanoflow cannot hold, an error-handling clause its activity // rejects (CE6035), a Binary return. They ran only inside exec, so `check` From 974e78b0f5c3e90951d88260adf426dbd25591ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:13:40 +0000 Subject: [PATCH 12/22] fix: documents() never returns message definitions The catalog had no table for MessageDefinitions$MessageDefinitionCollection or its Mendix 11.15 replacement MessageDefinition2, so the objects view behind documents() never held one and a naming rule on them could not fire. Index both (message_definition_collections, message_definitions), union them into objects as MESSAGE_DEFINITION_COLLECTION and MESSAGE_DEFINITION, map them for bare DESCRIBE, and bump the catalog schema to 26 so cached catalogs rebuild. Fixes #1433 (mendixlabs/mxcli#1535) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FiK7bYZEqF75x9DeX6Eyrm --- ...17-documents-omit-message-definitions.json | 1 + ...433-lint-documents-message-definitions.mdl | 34 ++++++++ mdl/catalog/builder.go | 8 ++ mdl/catalog/catalog.go | 2 + mdl/catalog/tables.go | 41 ++++++++- mdl/executor/describe_auto.go | 28 +++--- .../documents_message_definitions_test.go | 85 +++++++++++++++++++ 7 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-17-documents-omit-message-definitions.json create mode 100644 mdl-examples/bug-tests/1433-lint-documents-message-definitions.mdl create mode 100644 mdl/linter/documents_message_definitions_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-documents-omit-message-definitions.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-documents-omit-message-definitions.json new file mode 100644 index 000000000..31d841300 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-documents-omit-message-definitions.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-10-17", "symptom": "A message definition collection created with MDL does not appear in documents(), so a lint rule on its name (a required MD_ prefix) can never fire.", "cause": "documents() reads the catalog objects view, and the catalog had no table for MessageDefinitions$MessageDefinitionCollection or its 11.15 replacement MessageDefinitions$MessageDefinition2, so neither was ever unioned into objects.", "file": "mdl/catalog/tables.go, mdl/catalog/builder.go, mdl/executor/describe_auto.go, mdl/linter/documents_message_definitions_test.go", "insight": "A document type missing from documents() is a missing catalog table, not a linter bug: buildSimpleNamedDocs plus a view, an objects UNION arm, a Tables() entry and a schema bump is the whole recipe, and TestTables_CoversAllViews / TestDescribeAutoCoversCatalogObjectTypes name every other place that has to agree. Cover both storage shapes of a type Mendix replaced in place. For the control, pick a kind built the same way (raw units): JSON structures go through ListJsonStructures, so a raw-unit mock leaves them out and the control fails for the wrong reason.", "refs": "ako/mxcli#1433, mendixlabs/mxcli#1535"} diff --git a/mdl-examples/bug-tests/1433-lint-documents-message-definitions.mdl b/mdl-examples/bug-tests/1433-lint-documents-message-definitions.mdl new file mode 100644 index 000000000..57e8cc261 --- /dev/null +++ b/mdl-examples/bug-tests/1433-lint-documents-message-definitions.mdl @@ -0,0 +1,34 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1433 (mendixlabs/mxcli#1535) — documents() never returned message +-- definitions +-- ============================================================================ +-- +-- Reported: "A message definition collection created with MDL does not appear +-- in documents(), so a rule on its name (for example a required MD_ prefix) +-- can never fire." +-- +-- The catalog had no table for either storage shape, so the objects view that +-- documents() reads never held one. It now has message_definition_collections +-- (MESSAGE_DEFINITION_COLLECTION, Mendix < 11.15) and message_definitions +-- (MESSAGE_DEFINITION, the 11.15 MessageDefinition2 document). +-- +-- The assertion is a Go test (mdl/linter/documents_message_definitions_test.go): +-- the symptom is in the lint API, which `make check-mdl` does not exercise. To +-- reproduce by hand, run this script, then lint a rule returning +-- [violation(message = d.qualified_name) for d in documents() +-- if d.kind == "MESSAGE_DEFINITION_COLLECTION" and not d.name.startswith("MD_")] +-- It must report Msg.Orders. +-- ============================================================================ + +create module Msg; + +create persistent entity Msg.Order ( + Code: String(20) +); + +create message definition collection Msg.Orders { + definition Order for Msg.Order { + Code + } +}; diff --git a/mdl/catalog/builder.go b/mdl/catalog/builder.go index 6ed204bb5..1a0c47f67 100644 --- a/mdl/catalog/builder.go +++ b/mdl/catalog/builder.go @@ -470,6 +470,14 @@ func (b *Builder) Build(progress ProgressFunc) error { return fmt.Errorf("failed to build data transformers: %w", err) } + if err := b.buildSimpleNamedDocs("MessageDefinitions$MessageDefinitionCollection", "message_definition_collections", "Message Definition Collections"); err != nil { + return fmt.Errorf("failed to build message definition collections: %w", err) + } + + if err := b.buildSimpleNamedDocs("MessageDefinitions$MessageDefinition2", "message_definitions", "Message Definitions"); err != nil { + return fmt.Errorf("failed to build message definitions: %w", err) + } + if err := b.buildScheduledEvents(); err != nil { return fmt.Errorf("failed to build scheduled events: %w", err) } diff --git a/mdl/catalog/catalog.go b/mdl/catalog/catalog.go index 0810dcc58..7c8438a23 100644 --- a/mdl/catalog/catalog.go +++ b/mdl/catalog/catalog.go @@ -128,6 +128,8 @@ func (c *Catalog) Tables() []string { "CATALOG.TASK_QUEUES", "CATALOG.REGULAR_EXPRESSIONS", "CATALOG.DATA_TRANSFORMERS", + "CATALOG.MESSAGE_DEFINITION_COLLECTIONS", + "CATALOG.MESSAGE_DEFINITIONS", "CATALOG.AGENTS", "CATALOG.AI_MODELS", "CATALOG.KNOWLEDGE_BASES", diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index d2463fc6b..bd60513ac 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,11 @@ package catalog // // History: // +// 26 (message definitions): message_definition_collections and +// message_definitions, unioned into objects as +// MESSAGE_DEFINITION_COLLECTION / MESSAGE_DEFINITION, so documents() +// lists them (ako/mxcli#1433). Without the bump a cached catalog has +// neither view and an objects view that omits them. // 25 (documentation in the strings index): strings gains a row for the // documentation of every element the catalog reads a Description for // (entities, attributes, constants, pages, ...), so SEARCH finds words @@ -136,7 +141,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "25" +const CatalogSchemaVersion = "26" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -634,6 +639,32 @@ func (c *Catalog) createTables() error { )`, viewWithFullSnapshot("data_transformers"), + // message definitions, in both storage shapes: the collection that + // holds several definitions (Mendix < 11.15) and the one-definition + // MessageDefinition2 document that replaced it (ako/mxcli#1433). + `CREATE TABLE IF NOT EXISTS message_definition_collections_data ( + Id TEXT PRIMARY KEY, + Name TEXT, + QualifiedName TEXT, + ModuleName TEXT, + Folder TEXT, + Description TEXT, + ProjectId TEXT, + SnapshotId TEXT + )`, + viewWithFullSnapshot("message_definition_collections"), + `CREATE TABLE IF NOT EXISTS message_definitions_data ( + Id TEXT PRIMARY KEY, + Name TEXT, + QualifiedName TEXT, + ModuleName TEXT, + Folder TEXT, + Description TEXT, + ProjectId TEXT, + SnapshotId TEXT + )`, + viewWithFullSnapshot("message_definitions"), + // agent-editor documents (CustomBlobDocuments, discriminated by // CustomDocumentType into agent / model / knowledge base / MCP service). `CREATE TABLE IF NOT EXISTS agents_data ( @@ -1360,6 +1391,14 @@ func (c *Catalog) createTables() error { ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource FROM data_transformers UNION ALL + SELECT Id, 'MESSAGE_DEFINITION_COLLECTION' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, + ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource + FROM message_definition_collections + UNION ALL + SELECT Id, 'MESSAGE_DEFINITION' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, + ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource + FROM message_definitions + UNION ALL SELECT Id, 'AGENT' as ObjectType, Name, QualifiedName, ModuleName, Folder, Description, ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource FROM agents diff --git a/mdl/executor/describe_auto.go b/mdl/executor/describe_auto.go index d8f3693d2..1e431fb4e 100644 --- a/mdl/executor/describe_auto.go +++ b/mdl/executor/describe_auto.go @@ -46,19 +46,21 @@ var objectTypeToDescribeKind = map[string]ast.DescribeObjectType{ "CONSUMED_REST_SERVICE": ast.DescribeRestClient, // The ObjectType values before mendixlabs/mxcli#1395, which a catalog // cached by an older build still holds. - "ODATA_CLIENT": ast.DescribeODataClient, - "ODATA_SERVICE": ast.DescribeODataService, - "REST_CLIENT": ast.DescribeRestClient, - "PUBLISHED_REST_SERVICE": ast.DescribePublishedRestService, - "BUSINESS_EVENT_SERVICE": ast.DescribeBusinessEventService, - "DATABASE_CONNECTION": ast.DescribeDatabaseConnection, - "IMAGE_COLLECTION": ast.DescribeImageCollection, - "ICON_COLLECTION": ast.DescribeIconCollection, - "DATA_TRANSFORMER": ast.DescribeDataTransformer, - "AGENT": ast.DescribeAgent, - "AI_MODEL": ast.DescribeModel, - "KNOWLEDGE_BASE": ast.DescribeKnowledgeBase, - "CONSUMED_MCP_SERVICE": ast.DescribeConsumedMCPService, + "ODATA_CLIENT": ast.DescribeODataClient, + "ODATA_SERVICE": ast.DescribeODataService, + "REST_CLIENT": ast.DescribeRestClient, + "PUBLISHED_REST_SERVICE": ast.DescribePublishedRestService, + "BUSINESS_EVENT_SERVICE": ast.DescribeBusinessEventService, + "DATABASE_CONNECTION": ast.DescribeDatabaseConnection, + "IMAGE_COLLECTION": ast.DescribeImageCollection, + "ICON_COLLECTION": ast.DescribeIconCollection, + "DATA_TRANSFORMER": ast.DescribeDataTransformer, + "MESSAGE_DEFINITION_COLLECTION": ast.DescribeMessageDefinitionCollection, + "MESSAGE_DEFINITION": ast.DescribeMessageDefinition, + "AGENT": ast.DescribeAgent, + "AI_MODEL": ast.DescribeModel, + "KNOWLEDGE_BASE": ast.DescribeKnowledgeBase, + "CONSUMED_MCP_SERVICE": ast.DescribeConsumedMCPService, } // DescribeKindFor maps a catalog `objects` view ObjectType to the DESCRIBE kind diff --git a/mdl/linter/documents_message_definitions_test.go b/mdl/linter/documents_message_definitions_test.go new file mode 100644 index 000000000..48db46103 --- /dev/null +++ b/mdl/linter/documents_message_definitions_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// "A message definition collection created with MDL does not appear in +// documents(), so a rule on its name (for example a required MD_ prefix) can +// never fire" (ako/mxcli#1433). Both storage shapes are covered: the +// collection (Mendix < 11.15) and the per-definition MessageDefinition2 +// document that replaced it. The data transformer is the control — it was +// already listed. +func TestDocumentsIncludeMessageDefinitions(t *testing.T) { + const mod = model.ID("mod-md") + raw := func(id, typ, name string) *types.RawUnit { + contents, err := bson.Marshal(bson.M{"$Type": typ, "Name": name, "Documentation": ""}) + if err != nil { + t.Fatal(err) + } + return &types.RawUnit{ID: model.ID(id), ContainerID: mod, Type: typ, Contents: contents} + } + units := []*types.RawUnit{ + raw("u-coll", "MessageDefinitions$MessageDefinitionCollection", "Orders"), + raw("u-md2", "MessageDefinitions$MessageDefinition2", "OrderMessage"), + raw("u-dt", "DataTransformers$DataTransformer", "DT_Order"), + } + be := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return &model.ProjectSettings{}, nil }, + ListModuleSettingsFunc: func() ([]*types.ModuleSettings, error) { return nil, nil }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{{BaseElement: model.BaseElement{ID: mod}, Name: "MD"}}, nil + }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { return nil, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return nil, nil }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { return &types.NavigationDocument{}, nil }, + ListRawUnitsByTypeFunc: func(typ string) ([]*types.RawUnit, error) { + var out []*types.RawUnit + for _, u := range units { + if u.Type == typ { + out = append(out, u) + } + } + return out, nil + }, + } + cat, err := catalog.New() + if err != nil { + t.Fatalf("catalog.New: %v", err) + } + t.Cleanup(func() { cat.Close() }) + b := catalog.NewBuilder(cat, be) + b.SetFullMode(true) + if err := b.Build(nil); err != nil { + t.Fatalf("catalog build: %v", err) + } + + got := map[string]string{} + for d := range linter.NewLintContext(cat, nil).Documents() { + got[d.QualifiedName] = d.Kind + } + for qn, kind := range map[string]string{ + "MD.DT_Order": "DATA_TRANSFORMER", // control + "MD.Orders": "MESSAGE_DEFINITION_COLLECTION", + "MD.OrderMessage": "MESSAGE_DEFINITION", + } { + if got[qn] != kind { + t.Errorf("documents() kind of %s = %q, want %q (all: %v)", qn, got[qn], kind, got) + } + } +} From 291d392ed95182cc97cbcdfcbf1b53d162712fb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:22:00 +0000 Subject: [PATCH 13/22] fix: a nanoflow's `export level api` is written, not dropped (mendixlabs/mxcli#1498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create nanoflow … export level api` parsed, passed check and exec reported "Created nanoflow", but the document was stored with ExportLevel "Hidden" and describe printed no clause; the same clause on a microflow was stored as API. Nanoflows share the microflowOptions rule, so the clause parsed, but ExitCreateNanoflowStatement read only FOLDER and EXPOSED AS. CreateNanoflowStmt and sdk Nanoflow now carry ExportLevel (absent = carry the stored level); nanoflowToGen writes an authored one and carryStoredNanoflowHeader stands down for it; the reader fills it; both describe renderers print `export level api`; the create-or-modify header diff treats an absent clause as the stored level. Measured on 11.13.0: create -> API; create or modify without the clause keeps API; with `hidden` -> Hidden; describe -> exec into a fresh project -> API; mx check 0 errors on both. genMf.VersionInfos records no introduction for MicroflowBase.exportLevel, so it is declared at every supported version. Control: with the non-test changes reverted the backend test stores "Hidden" (the reported symptom), and the exec and describe tests fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HJcUHPvAr7SDBcAoGHYcPQ --- ...noflow-export-level-api-stored-hidden.json | 1 + .../bug-tests/1498-nanoflow-export-level.mdl | 23 +++++++ mdl/ast/ast_microflow.go | 3 + mdl/backend/modelsdk/microflow.go | 1 + .../modelsdk/nanoflow_export_level_test.go | 54 +++++++++++++++ mdl/backend/modelsdk/nanoflow_write.go | 17 +++-- mdl/executor/cmd_flow_modify.go | 3 + mdl/executor/cmd_microflows_build.go | 5 ++ mdl/executor/cmd_microflows_show.go | 11 +++ mdl/executor/cmd_nanoflows_mock_test.go | 69 +++++++++++++++++++ mdl/visitor/visitor_microflow.go | 26 ++++--- sdk/microflows/microflows.go | 3 + 12 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor/2026-10-17-nanoflow-export-level-api-stored-hidden.json create mode 100644 mdl-examples/bug-tests/1498-nanoflow-export-level.mdl create mode 100644 mdl/backend/modelsdk/nanoflow_export_level_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-17-nanoflow-export-level-api-stored-hidden.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-17-nanoflow-export-level-api-stored-hidden.json new file mode 100644 index 000000000..5006c952f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-17-nanoflow-export-level-api-stored-hidden.json @@ -0,0 +1 @@ +{"area":"mdl/visitor","date":"2026-10-17","symptom":"`create nanoflow … export level api` passed check and exec reported 'Created nanoflow', but bson dump showed ExportLevel \"Hidden\" and describe printed no clause; the same clause on a microflow was stored as API","cause":"Nanoflows share the microflowOptions grammar rule, so the clause parsed — but ExitCreateNanoflowStatement read only FOLDER and EXPOSED AS from it, and CreateNanoflowStmt / sdk Nanoflow had no field to carry it","file":"mdl/visitor/visitor_microflow.go","insight":"A grammar rule shared between two statements gives both the syntax, not both the semantics: every option a shared rule accepts must be read, or refused, by EACH visitor that uses it — URL and concurrency clauses on a nanoflow still parse and drop the same way (Nanoflows$Nanoflow has no such properties, so those want a refusal, not a write). describe nanoflow has two renderers (describeNanoflow for the command, renderMicroflowMDL via nanoflowAsMicroflow for create-or-modify's comparison); fixing one made the re-run report 'Unchanged' while the describe command still omitted the clause — test both. Version safety came from genMf.VersionInfos: MicroflowBase.exportLevel has no Introduced entry (returnVariableName has 10.12.0), so the key is declared at every supported version; the Model SDK npm package (4.116) has no exportLevel at all and cannot arbitrate this one.","refs":["mendixlabs/mxcli#1498","ako/mxcli#1351"]} diff --git a/mdl-examples/bug-tests/1498-nanoflow-export-level.mdl b/mdl-examples/bug-tests/1498-nanoflow-export-level.mdl new file mode 100644 index 000000000..f8dede536 --- /dev/null +++ b/mdl-examples/bug-tests/1498-nanoflow-export-level.mdl @@ -0,0 +1,23 @@ +mdl 1; +-- Bug (mendixlabs/mxcli#1498, follow-up comment): `export level api` on a +-- nanoflow parsed, `check` passed and exec reported "Created nanoflow", but the +-- document was stored with ExportLevel "Hidden" and describe printed no clause. +-- The same clause on a microflow was stored as API. The visitor read the +-- clause for CREATE MICROFLOW only. +-- +-- Measured on 11.13.0, this file: +-- pre-fix bson dump: nanoflow ExportLevel "Hidden", microflow "API" +-- fixed nanoflow "API"; describe prints `export level api` +-- create or modify without the clause -> API kept ("set: Documentation") +-- with `export level hidden` -> Hidden; describe -> exec into a fresh +-- project -> API; mx check 0 errors on both projects +create module if not exists Bug1498Nf; +create persistent entity Bug1498Nf.Order (Number: String(50)); + +create or modify nanoflow Bug1498Nf.NF_Order_IsOpen ($Order: Bug1498Nf.Order) + returns Boolean as $IsOpen + export level api +begin + declare $IsOpen Boolean = true; + return $IsOpen; +end; diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 2c440c477..2af068099 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -173,6 +173,9 @@ type CreateNanoflowStmt struct { // properties. Accepting it in the grammar and explaining the refusal beats a // parse error that says only "no viable alternative". Expose []ExposeActionClause + // ExportLevel is "API" or "Hidden"; nil preserves. Nanoflows$Nanoflow + // stores it like a microflow does (mendixlabs/mxcli#1498). + ExportLevel *string } func (s *CreateNanoflowStmt) isStatement() {} diff --git a/mdl/backend/modelsdk/microflow.go b/mdl/backend/modelsdk/microflow.go index f6697429c..7bb63efa1 100644 --- a/mdl/backend/modelsdk/microflow.go +++ b/mdl/backend/modelsdk/microflow.go @@ -156,6 +156,7 @@ func nanoflowFromGen(nf *genMf.Nanoflow, containerID model.ID) *microflows.Nanof MarkAsUsed: nf.MarkAsUsed(), ReturnType: dataTypeFromGen(nf.MicroflowReturnType()), ReturnVariableName: nf.ReturnVariableName(), + ExportLevel: nf.ExportLevel(), } out.ID = model.ID(nf.ID()) for _, qn := range nf.AllowedModuleRolesQualifiedNames() { diff --git a/mdl/backend/modelsdk/nanoflow_export_level_test.go b/mdl/backend/modelsdk/nanoflow_export_level_test.go new file mode 100644 index 000000000..5c15e864d --- /dev/null +++ b/mdl/backend/modelsdk/nanoflow_export_level_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1498 (follow-up): `export level api` on a nanoflow parsed, +// check passed, exec reported "Created nanoflow", and the document was stored +// with ExportLevel "Hidden" — the clause never reached the writer. A microflow +// with the same clause was stored as API. +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestCreateNanoflow_WritesAuthoredExportLevel(t *testing.T) { + b, _ := nanoflowFixture(t) + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + nf := µflows.Nanoflow{ContainerID: mod.ID, Name: "ZzApiNanoflow", ExportLevel: "API"} + if err := b.CreateNanoflow(nf); err != nil { + t.Fatalf("CreateNanoflow: %v", err) + } + if got := storedKeys(t, b, nf.ID)["ExportLevel"]; got != "API" { + t.Errorf("ExportLevel = %#v, want the authored API", got) + } + if got, err := b.GetNanoflow(nf.ID); err != nil || got.ExportLevel != "API" { + t.Errorf("read back ExportLevel = %q (%v), want API", got.ExportLevel, err) + } +} + +// An authored level wins over the stored one, in both directions: Hidden over +// API alone cannot tell a writer that honours the clause from one that writes +// the "Hidden" constant (the #816 lesson). Unauthored still carries the stored +// level — TestUpdateNanoflow_CarriesStoredHeaderKeys. +func TestUpdateNanoflow_AuthoredExportLevelWins(t *testing.T) { + b, nf := nanoflowFixture(t) + for _, tc := range []struct{ stored, authored string }{ + {"Hidden", "API"}, + {"API", "Hidden"}, + } { + setStoredKeys(t, b, nf.ID, bson.D{{Key: "ExportLevel", Value: tc.stored}}) + nf.ExportLevel = tc.authored + if err := b.UpdateNanoflow(nf); err != nil { + t.Fatalf("UpdateNanoflow: %v", err) + } + if got := storedKeys(t, b, nf.ID)["ExportLevel"]; got != tc.authored { + t.Errorf("stored %s, authored %s: ExportLevel = %#v", tc.stored, tc.authored, got) + } + } +} diff --git a/mdl/backend/modelsdk/nanoflow_write.go b/mdl/backend/modelsdk/nanoflow_write.go index e1972d09f..ce1f4ab44 100644 --- a/mdl/backend/modelsdk/nanoflow_write.go +++ b/mdl/backend/modelsdk/nanoflow_write.go @@ -18,7 +18,7 @@ import ( // CreateNanoflow inserts a new Microflows$Nanoflow document unit. A nanoflow // shares the microflow flow model (parameters + object collection + sequence // flows), so it reuses the microflow object/flow converters; only the top-level -// field set differs (no ExportLevel / concurrency / URL fields). +// field set differs (no concurrency / URL fields). func (b *Backend) CreateNanoflow(nf *microflows.Nanoflow) error { if nf == nil { return fmt.Errorf("CreateNanoflow: nil nanoflow") @@ -92,6 +92,13 @@ func nanoflowToGen(nf *microflows.Nanoflow, v flowVersion) *genMf.Nanoflow { if nf.ReturnVariableName != "" && v.hasReturnVariableName() { out.SetReturnVariableName(nf.ReturnVariableName) } + // Only when authored (`export level api | hidden`, mendixlabs/mxcli#1498); + // UpdateNanoflow carries a stored one otherwise. The metamodel's version + // data records no introduction for MicroflowBase.exportLevel, so it is + // declared at every version the writer supports. + if nf.ExportLevel != "" { + out.SetExportLevel(nf.ExportLevel) + } oc := genMf.NewMicroflowObjectCollection() for i, p := range nf.Parameters { @@ -122,9 +129,9 @@ func nanoflowToGen(nf *microflows.Nanoflow, v flowVersion) *genMf.Nanoflow { } // carryStoredNanoflowHeader copies onto a rebuilt nanoflow the header keys the -// statement did not author: ExportLevel and UseListParameterByReference, which -// MDL has no spelling for, and ReturnVariableName unless the statement gave -// `returns T as $Var`. A rebuild used to omit all three, so describe -> exec +// statement did not author: UseListParameterByReference, which MDL has no +// spelling for, ExportLevel unless the statement gave `export level …`, and +// ReturnVariableName unless it gave `returns T as $Var`. A rebuild used to omit all three, so describe -> exec // deleted them (ako/mxcli#705). // // Only a key the stored document carries is written. The nanoflow writer has @@ -146,7 +153,7 @@ func (b *Backend) carryStoredNanoflowHeader(nf *microflows.Nanoflow, g *genMf.Na for _, e := range stored { switch e.Key { case "ExportLevel": - if v, ok := e.Value.(string); ok && v != "" { + if v, ok := e.Value.(string); ok && v != "" && nf.ExportLevel == "" { g.SetExportLevel(v) } case "UseListParameterByReference": diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index c63fd0060..e23c0989b 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -718,6 +718,9 @@ func nanoflowDecl(s *ast.CreateNanoflowStmt) *flowDecl { if dh.Expose == nil { dh.Expose = sh.Expose } + if dh.ExportLevel == nil { + dh.ExportLevel = sh.ExportLevel + } return &dh, &sh }, } diff --git a/mdl/executor/cmd_microflows_build.go b/mdl/executor/cmd_microflows_build.go index dd1d3e9bc..511fdbc71 100644 --- a/mdl/executor/cmd_microflows_build.go +++ b/mdl/executor/cmd_microflows_build.go @@ -580,6 +580,11 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui if preserveDocumentation { nf.Documentation = carriedDocumentation(s.DocumentationSet, s.Documentation, existingDocumentation) } + // Authored only; absent leaves "" and the backend carries the stored level + // (mendixlabs/mxcli#1498). + if s.ExportLevel != nil { + nf.ExportLevel = *s.ExportLevel + } // A new flow starts with no allowed roles: access is the author's grant // (mendixlabs/mxcli#1474). if preserveAllowedRoles { diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index 6a43c4344..3f0178037 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -462,6 +462,11 @@ func describeNanoflow(ctx *ExecContext, name ast.QualifiedName) error { } } + // Default Hidden is not printed, as for a microflow (mendixlabs/mxcli#1498). + if targetNf.ExportLevel == types.ExportLevelAPI { + lines = append(lines, "export level api") + } + // Folder if folderPath := h.BuildFolderPath(targetNf.ContainerID); folderPath != "" { lines = append(lines, fmt.Sprintf("folder %s", mdlQuote(ctx, folderPath))) @@ -615,6 +620,7 @@ func nanoflowAsMicroflow(nf *microflows.Nanoflow) *microflows.Microflow { Parameters: nf.Parameters, ReturnType: nf.ReturnType, ReturnVariableName: nf.ReturnVariableName, + ExportLevel: nf.ExportLevel, ObjectCollection: nf.ObjectCollection, AllowedModuleRoles: nf.AllowedModuleRoles, } @@ -689,6 +695,11 @@ func renderMicroflowMDL( lines = append(lines, exposeClauseLines(mf)...) if flowType == "microflow" { lines = append(lines, microflowDocumentPropertyLines(mf)...) + } else if flowType == "nanoflow" && mf.ExportLevel == types.ExportLevelAPI { + // A nanoflow has an export level and nothing else of the three: the + // wrapped Microflow's zero AllowConcurrentExecution would otherwise + // print a `disallow concurrent execution` no nanoflow can hold. + lines = append(lines, "export level api") } lines = append(lines, "begin") diff --git a/mdl/executor/cmd_nanoflows_mock_test.go b/mdl/executor/cmd_nanoflows_mock_test.go index ed0edf862..36bec4e54 100644 --- a/mdl/executor/cmd_nanoflows_mock_test.go +++ b/mdl/executor/cmd_nanoflows_mock_test.go @@ -1031,3 +1031,72 @@ func TestDescribeNanoflow_Mock_EmitsGrantForAllowedRoles(t *testing.T) { t.Errorf("grant roles = %q, want %q", got, "Shop.User, Shop.Admin") } } + +// mendixlabs/mxcli#1498 (follow-up): `export level api` on a nanoflow parsed +// and passed check, and exec stored Hidden — the clause never left the parser. +func TestCreateNanoflow_ExportLevelReachesTheWriter(t *testing.T) { + var written *microflows.Nanoflow + modID := model.ID("mod-shop") + mod := &model.Module{BaseElement: model.BaseElement{ID: modID}, Name: "Shop"} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(string) (*model.Module, error) { return mod, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return nil, nil }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + CreateNanoflowFunc: func(nf *microflows.Nanoflow) error { written = nf; return nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + for _, tc := range []struct{ clause, want string }{ + {"export level api", types.ExportLevelAPI}, + {"export level hidden", types.ExportLevelHidden}, + // Control: no clause authors nothing, so the stored level is carried. + {"", ""}, + } { + written = nil + stmt := firstStatement[*ast.CreateNanoflowStmt](t, "create nanoflow Shop.NF_IsOpen ()\n"+tc.clause+"\nbegin\nend;") + if err := execCreateNanoflow(ctx, stmt); err != nil { + t.Fatalf("%q: %v", tc.clause, err) + } + if written == nil || written.ExportLevel != tc.want { + t.Errorf("%q: written ExportLevel = %+v, want %q", tc.clause, written, tc.want) + } + } +} + +// DESCRIBE prints the clause for an API nanoflow, and only that clause: the +// microflow-only concurrency and URL lines have no nanoflow property. Both +// renderers are checked — `describe nanoflow` and the one `create or modify` +// compares against — because they are separate code and only one was fixed at +// first. +func TestDescribeNanoflow_EmitsExportLevel(t *testing.T) { + for _, tc := range []struct{ level, want string }{ + {"API", "export level api"}, + // Control: Hidden is the default and is not printed. + {"Hidden", ""}, + } { + mod := mkModule("Shop") + nf := mkNanoflow(mod.ID, "NF_IsOpen") + nf.ExportLevel = tc.level + h := mkHierarchy(mod) + withContainer(h, nf.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return []*microflows.Nanoflow{nf}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + qn := ast.QualifiedName{Module: "Shop", Name: "NF_IsOpen"} + assertNoError(t, describeNanoflow(ctx, qn)) + rendered := renderMicroflowMDL(ctx, "nanoflow", nanoflowAsMicroflow(nf), qn, nil, nil, nil) + for label, out := range map[string]string{"describe nanoflow": buf.String(), "modify comparison": rendered} { + if got := strings.Contains(out, "export level"); got != (tc.want != "") || (tc.want != "" && !strings.Contains(out, tc.want)) { + t.Errorf("%s, stored %s: want %q in\n%s", label, tc.level, tc.want, out) + } + if strings.Contains(out, "concurrent") { + t.Errorf("%s prints a concurrency clause a nanoflow cannot hold:\n%s", label, out) + } + } + } +} diff --git a/mdl/visitor/visitor_microflow.go b/mdl/visitor/visitor_microflow.go index 570a9cc27..0c56c9635 100644 --- a/mdl/visitor/visitor_microflow.go +++ b/mdl/visitor/visitor_microflow.go @@ -91,6 +91,12 @@ func (b *Builder) ExitCreateNanoflowStatement(ctx *parser.CreateNanoflowStatemen if exposed := optCtx.MicroflowExposedClause(); exposed != nil { stmt.Expose = append(stmt.Expose, buildExposeActionClause(exposed)) } + // A nanoflow stores an export level like a microflow. The clause + // parsed here and was dropped, so `export level api` wrote Hidden + // (mendixlabs/mxcli#1498). + if el := optCtx.MicroflowExportLevelClause(); el != nil { + stmt.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) + } } } @@ -453,6 +459,17 @@ func applyEntityAccessAnnotation(createStmt parser.ICreateStatementContext) *boo return nil } +// exportLevelOf maps an EXPORT LEVEL clause's keyword to the member's stored +// spelling — never the user's casing, which is not what MicroflowsExportLevel +// declares. +func exportLevelOf(elCtx *parser.MicroflowExportLevelClauseContext) *string { + level := types.ExportLevelHidden + if elCtx.API() != nil { + level = types.ExportLevelAPI + } + return &level +} + // applyMicroflowDocumentProperty reads the URL / EXPORT LEVEL / concurrency // header clauses onto the statement. // @@ -486,14 +503,7 @@ func applyMicroflowDocumentProperty(stmt *ast.CreateMicroflowStmt, optCtx *parse } if el := optCtx.MicroflowExportLevelClause(); el != nil { - elCtx := el.(*parser.MicroflowExportLevelClauseContext) - // The keyword, mapped to the member's stored spelling — never the user's - // casing, which is not what MicroflowsExportLevel declares. - level := types.ExportLevelHidden - if elCtx.API() != nil { - level = types.ExportLevelAPI - } - stmt.ExportLevel = &level + stmt.ExportLevel = exportLevelOf(el.(*parser.MicroflowExportLevelClauseContext)) } if c := optCtx.MicroflowConcurrencyClause(); c != nil { diff --git a/sdk/microflows/microflows.go b/sdk/microflows/microflows.go index f06d78daa..c10f451a3 100644 --- a/sdk/microflows/microflows.go +++ b/sdk/microflows/microflows.go @@ -131,6 +131,9 @@ type Nanoflow struct { // ReturnVariableName is the `as $Var` of `returns T as $Var`. Empty on a // write means "not authored": the backend then carries the stored value. ReturnVariableName string `json:"returnVariableName,omitempty"` + // ExportLevel is `Hidden` or `API`, as on a Microflow. Empty on a write + // means "not authored": the backend then carries the stored value. + ExportLevel string `json:"exportLevel,omitempty"` // Parameters Parameters []*MicroflowParameter `json:"parameters,omitempty"` From 257bf88d81a120dcaff90056cc72af5910ed65fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:23:27 +0000 Subject: [PATCH 14/22] fix: user_roles() and the catalog do not expose a user role's description The backend already read UserRole.Description, but the lint struct dropped it, so a rule "every user role is documented" could not be written; the catalog had only role_mappings, with no row per user role. Add `description` to user_roles() and a CATALOG.USER_ROLES table (Name, Description, IsAnonymous, ModuleRoleCount), schema 27. DESCRIBE/CREATE USER ROLE already carry Description on main (#707). Fixes #1442 (mendixlabs/mxcli#1544) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FiK7bYZEqF75x9DeX6Eyrm --- ...-17-user-role-description-not-exposed.json | 1 + .../skills/mendix/manage-security/SKILL.md | 13 ++-- .../skills/mendix/write-lint-rules/SKILL.md | 9 +-- .../bug-tests/1442-user-role-description.mdl | 32 ++++++++++ mdl/catalog/builder_roles.go | 34 +++++++++- mdl/catalog/builder_user_roles_test.go | 62 +++++++++++++++++++ mdl/catalog/catalog.go | 1 + mdl/catalog/tables.go | 21 ++++++- mdl/linter/context.go | 2 + mdl/linter/starlark.go | 1 + mdl/linter/starlark_catalog_tables_test.go | 27 ++++++++ 11 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-17-user-role-description-not-exposed.json create mode 100644 mdl-examples/bug-tests/1442-user-role-description.mdl create mode 100644 mdl/catalog/builder_user_roles_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-user-role-description-not-exposed.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-user-role-description-not-exposed.json new file mode 100644 index 000000000..c35120be2 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-user-role-description-not-exposed.json @@ -0,0 +1 @@ +{"area": "mdl/linter", "date": "2026-10-17", "symptom": "A user role's Documentation/Description in Project Security is not exposed: user_roles() in Starlark has no description (module roles do), and the catalog has no user roles table with it; DESCRIBE USER ROLE was reported to leave it out.", "cause": "The backend already read UserRole.Description, but UserRoleInfo dropped it and userRoleToStarlark had no field for it; the catalog only had role_mappings, one row per user-role/module-role pair, with nowhere to put a per-role property. DESCRIBE/CREATE had been fixed separately (ako/mxcli#707) after the reporter's v0.26.0.", "file": "mdl/linter/context.go (UserRoles), mdl/linter/starlark.go (userRoleToStarlark), mdl/catalog/builder_roles.go (buildUserRoles), mdl/catalog/tables.go", "insight": "Check each named surface on current main before fixing a multi-surface report: one of the three (DESCRIBE) was already fixed, and re-fixing it would have been churn. A field the backend already decodes but a lint struct omits surfaces as 'rule needs a newer mxcli (struct has no .description attribute)', which reads like a version problem rather than an omission. Two parallel catalog changes need distinct schema numbers (26 vs 27), or a cache built by one never rebuilds for the other.", "refs": "ako/mxcli#1442, mendixlabs/mxcli#1544"} diff --git a/.claude/skills/mendix/manage-security/SKILL.md b/.claude/skills/mendix/manage-security/SKILL.md index ccc9d690b..e1bce19e8 100644 --- a/.claude/skills/mendix/manage-security/SKILL.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -13,11 +13,11 @@ description: "Configure Mendix security in MDL — module and user roles, access | 27-35 | When to Use This Skill | | 36-43 | Security Concepts | | 44-120 | The System-module ceiling — decide the role model around this FIRST | -| 121-552 | Syntax Reference | -| 553-567 | Starlark Lint Rule APIs | -| 568-620 | Common Workflow: Setting Up Module Security | -| 621-630 | Common Mistakes | -| 631-640 | Validation | +| 121-553 | Syntax Reference | +| 554-568 | Starlark Lint Rule APIs | +| 569-621 | Common Workflow: Setting Up Module Security | +| 622-631 | Common Mistakes | +| 632-641 | Validation | # Security Management Skill @@ -191,6 +191,7 @@ describe CATALOG.ROLE_MAPPINGS; | Table | Contents | Build mode | |-------|----------|------------| | `CATALOG.PERMISSIONS` | Entity CRUD, microflow EXECUTE, page VIEW, OData ACCESS | `refresh catalog full` | +| `CATALOG.USER_ROLES` | User roles with their `Description`, `IsAnonymous` and `ModuleRoleCount` | `refresh catalog` | | `CATALOG.ROLE_MAPPINGS` | User role → module role assignments | `refresh catalog` | ### Module Roles @@ -558,7 +559,7 @@ Security data is available in Starlark lint rules (`.star` files): |----------|---------|-------------| | `permissions()` | list of permission | All permissions across all element types | | `permissions_for(qn)` | list of permission | Permissions for a specific entity | -| `user_roles()` | list of user_role | User roles with module role assignments | +| `user_roles()` | list of user_role | User roles with their description and module role assignments | | `module_roles()` | list of module_role | Distinct module roles | | `role_mappings()` | list of role_mapping | User role → module role mappings | | `project_security()` | struct or None | Security level, guest access, password policy | diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index f0cf7cbeb..12e120d44 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -12,10 +12,10 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b |---|---| | 25-67 | Rule File Structure | | 68-161 | Available Query Functions | -| 162-600 | Object Properties | -| 601-613 | Helper Functions | -| 614-700 | Common Patterns | -| 701-710 | Validation | +| 162-601 | Object Properties | +| 602-614 | Helper Functions | +| 615-701 | Common Patterns | +| 702-711 | Validation | # Writing Custom Starlark Lint Rules @@ -535,6 +535,7 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | Property | Type | Example | |----------|------|---------| | `name` | string | `"Administrator"` | +| `description` | string | The role's documentation in Project Security; `""` when none | | `is_anonymous` | bool | True if this is the anonymous/guest role | | `module_roles` | list of string | `["Sales.Admin", "HR.Viewer"]` | diff --git a/mdl-examples/bug-tests/1442-user-role-description.mdl b/mdl-examples/bug-tests/1442-user-role-description.mdl new file mode 100644 index 000000000..f72129c68 --- /dev/null +++ b/mdl-examples/bug-tests/1442-user-role-description.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1442 (mendixlabs/mxcli#1544) — a user role's documentation was not +-- exposed to lint rules or the catalog +-- ============================================================================ +-- +-- Reported: "A user role has a Documentation/Description in Project Security, +-- but mxcli does not expose it: DESCRIBE USER ROLE leaves it out, the catalog +-- has no user roles table with it, and user_roles() in Starlark has no +-- description (module roles do)." +-- +-- DESCRIBE / CREATE USER ROLE already carry Description on main (ako/mxcli#707, +-- after the reporter's v0.26.0); describing Planner below prints it back. +-- What remained: user_roles() now has `description`, and CATALOG.USER_ROLES +-- has a Description column. +-- +-- The assertions are Go tests (mdl/linter/starlark_catalog_tables_test.go, +-- mdl/catalog/builder_user_roles_test.go): the symptom is in the lint API and +-- the catalog, which `make check-mdl` does not exercise. To reproduce by hand, +-- run this script, then +-- select Name, Description from CATALOG.USER_ROLES +-- and lint a rule returning +-- [violation(message = r.name) for r in user_roles() if not r.description] +-- Planner must carry its text and not be reported. +-- ============================================================================ + +create or modify user role Planner ( + Description: 'Plans the routes; maps to the dispatcher desk', + CheckSecurity: true +); + +describe user role Planner; diff --git a/mdl/catalog/builder_roles.go b/mdl/catalog/builder_roles.go index 4007c2a20..c068c4a04 100644 --- a/mdl/catalog/builder_roles.go +++ b/mdl/catalog/builder_roles.go @@ -2,7 +2,11 @@ package catalog -import "strings" +import ( + "strings" + + "github.com/mendixlabs/mxcli/sdk/security" +) // buildRoleMappings populates the role_mappings table from project security. // This maps user roles to their assigned module roles. @@ -42,5 +46,33 @@ func (b *Builder) buildRoleMappings() error { } b.report("RoleMappings", count) + + return b.buildUserRoles(ps) +} + +// buildUserRoles writes one user_roles row per project user role, with the +// documentation a "every user role is documented" rule needs (ako/mxcli#1442). +func (b *Builder) buildUserRoles(ps *security.ProjectSecurity) error { + stmt, err := b.tx.Prepare(` + INSERT INTO user_roles (Name, Description, IsAnonymous, ModuleRoleCount, ProjectId, SnapshotId) + VALUES (?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer stmt.Close() + + for _, ur := range ps.UserRoles { + isAnonymous := 0 + if ur.Name == ps.GuestUserRole { + isAnonymous = 1 + } + if _, err := stmt.Exec(ur.Name, ur.Description, isAnonymous, len(ur.ModuleRoles), + b.catalog.projectID, b.snapshot.ID); err != nil { + return err + } + } + + b.report("User Roles", len(ps.UserRoles)) return nil } diff --git a/mdl/catalog/builder_user_roles_test.go b/mdl/catalog/builder_user_roles_test.go new file mode 100644 index 000000000..d87fcc17b --- /dev/null +++ b/mdl/catalog/builder_user_roles_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "fmt" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// ako/mxcli#1442: "the catalog has no user roles table with it" — a user +// role's documentation could not be queried from the catalog. The +// undocumented, anonymous role is the control for both columns. +func TestCatalogUserRolesCarryDescription(t *testing.T) { + be := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return &model.ProjectSettings{}, nil }, + ListModuleSettingsFunc: func() ([]*types.ModuleSettings, error) { return nil, nil }, + ListModulesFunc: func() ([]*model.Module, error) { return nil, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { return nil, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return nil, nil }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { return &types.NavigationDocument{}, nil }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{ + GuestUserRole: "Guest", + UserRoles: []*security.UserRole{ + {Name: "Planner", Description: "Plans the routes", ModuleRoles: []string{"Ops.Planner", "Ops.User"}}, + {Name: "Guest", ModuleRoles: []string{"Ops.Anonymous"}}, + }, + }, nil + }, + } + cat, err := New() + if err != nil { + t.Fatalf("catalog.New: %v", err) + } + t.Cleanup(func() { cat.Close() }) + b := NewBuilder(cat, be) + if err := b.Build(nil); err != nil { + t.Fatalf("catalog build: %v", err) + } + + got := map[string]string{} + for _, row := range queryRows(t, cat, `SELECT Name, Description, IsAnonymous, ModuleRoleCount FROM user_roles`) { + got[row[0].(string)] = fmt.Sprintf("%v|%v|%v", row[1], row[2], row[3]) + } + want := map[string]string{ + "Planner": "Plans the routes|0|2", + "Guest": "|1|1", + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("user_roles rows = %v, want %v", got, want) + } +} diff --git a/mdl/catalog/catalog.go b/mdl/catalog/catalog.go index 0810dcc58..555c2b49c 100644 --- a/mdl/catalog/catalog.go +++ b/mdl/catalog/catalog.go @@ -141,6 +141,7 @@ func (c *Catalog) Tables() []string { "CATALOG.WIDGET_DEFINITION_PROPERTIES", "CATALOG.XPATH_EXPRESSIONS", "CATALOG.REFS", + "CATALOG.USER_ROLES", "CATALOG.ROLE_MAPPINGS", "CATALOG.PERMISSIONS", "CATALOG.PROJECTS", diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index d2463fc6b..e5eb3989e 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,11 @@ package catalog // // History: // +// 27 (user roles): a user_roles table with each project user role's +// Description (ako/mxcli#1442). Its own number, not 26: the message +// definitions change took 26 in parallel, and a cache built at 26 by +// either alone would never rebuild for the other. Without the bump a +// cached catalog has no user_roles table at all. // 25 (documentation in the strings index): strings gains a row for the // documentation of every element the catalog reads a Description for // (entities, attributes, constants, pages, ...), so SEARCH finds words @@ -136,7 +141,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "25" +const CatalogSchemaVersion = "27" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -158,7 +163,7 @@ const MetaSchemaVersion = "schema_version" // // Tables that were already clean (only `ProjectId` + `SnapshotId`, no // denormalized columns) keep their original shape: -// navigation_menu_items, navigation_role_homes, role_mappings, refs, +// navigation_menu_items, navigation_role_homes, user_roles, role_mappings, refs, // permissions, constant_values. func (c *Catalog) createTables() error { schemas := []string{ @@ -1156,6 +1161,18 @@ func (c *Catalog) createTables() error { )`, viewWithFullSnapshot("jar_dependencies"), + // user_roles — one row per project user role, carrying the + // documentation Studio Pro shows in Project Security (ako/mxcli#1442). + `CREATE TABLE IF NOT EXISTS user_roles ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL, + Description TEXT, + IsAnonymous INTEGER, + ModuleRoleCount INTEGER, + ProjectId TEXT, + SnapshotId TEXT + )`, + // role_mappings, refs, permissions, constant_values — already clean `CREATE TABLE IF NOT EXISTS role_mappings ( Id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/mdl/linter/context.go b/mdl/linter/context.go index a3681687f..9e9563e04 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -484,6 +484,7 @@ func (ctx *LintContext) Permissions() iter.Seq[AllPermission] { // UserRoleInfo represents a user role from project security. type UserRoleInfo struct { Name string + Description string IsAnonymous bool ModuleRoles []string } @@ -503,6 +504,7 @@ func (ctx *LintContext) UserRoles() []UserRoleInfo { for _, ur := range ps.UserRoles { roles = append(roles, UserRoleInfo{ Name: ur.Name, + Description: ur.Description, IsAnonymous: ur.Name == ps.GuestUserRole, ModuleRoles: ur.ModuleRoles, }) diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index b2d997b3f..f0ff3aa1f 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -1168,6 +1168,7 @@ func userRoleToStarlark(ur UserRoleInfo) starlark.Value { } return starlarkstruct.FromStringDict(starlark.String("user_role"), starlark.StringDict{ "name": starlark.String(ur.Name), + "description": starlark.String(ur.Description), "is_anonymous": starlark.Bool(ur.IsAnonymous), "module_roles": starlark.NewList(moduleRoles), }) diff --git a/mdl/linter/starlark_catalog_tables_test.go b/mdl/linter/starlark_catalog_tables_test.go index ea6c7bec1..c2a83d51b 100644 --- a/mdl/linter/starlark_catalog_tables_test.go +++ b/mdl/linter/starlark_catalog_tables_test.go @@ -279,3 +279,30 @@ def check(): t.Errorf("project_security() exposes the admin password:\n%s", msg) } } + +// ako/mxcli#1442: "A user role has a Documentation/Description in Project +// Security, but ... user_roles() in Starlark has no description (module roles +// do)." The undocumented role is the control: it reads back as "". +func TestUserRolesExposeDescription(t *testing.T) { + cat, err := catalog.NewFromFile(filepath.Join(t.TempDir(), "cat.db")) + if err != nil { + t.Fatal(err) + } + defer cat.Close() + reader := &securityReader{ps: &security.ProjectSecurity{UserRoles: []*security.UserRole{ + {Name: "Planner", Description: "Plans the routes; maps to the dispatcher desk"}, + {Name: "User"}, + }}} + vs, _ := runSrc(t, linter.NewLintContext(cat, reader), ` +def check(): + return [violation(message=r.name + "=" + r.description) for r in user_roles()] +`) + var got []string + for _, v := range vs { + got = append(got, v.Message) + } + want := []string{"Planner=Plans the routes; maps to the dispatcher desk", "User="} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("user_roles() descriptions:\n%v\nwant:\n%v", got, want) + } +} From fd156675370c9a94f11ff11c9279bbf6aaf7f6b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:44:42 +0000 Subject: [PATCH 15/22] fix: parse_xpath drops association paths; lint rules cannot recurse parse_xpath fed the constraint to the microflow-expression parser, which has no entity-rooted path: `Mod.A/Mod.E/Name = 'x'` came back as just qname Mod.A. exprcheck gains an XPath mode, used only by parse_xpath, in which such a path is an attr_path with an empty variable and a per-segment `predicates` list; expression mode still reports it (E014). Rule files ran under Starlark's legacy options, which forbid recursion, so any tree walker -- including the write-lint-rules skill's count_not example -- failed with "function called recursively". Recursion is on (the interpreter still stops at 100,000 frames). The example itself also matched `not` as a call instead of a unary NOT and used `sum`, which Starlark lacks; it is rewritten and a test now runs it from the skill. matches() has no captures, so find_all(s, pattern) returns them, shaped like Python's re.findall. Fixes #1439 (mendixlabs/mxcli#1541) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FiK7bYZEqF75x9DeX6Eyrm --- ...parse-xpath-stops-at-association-path.json | 1 + .../skills/mendix/write-lint-rules/SKILL.md | 40 ++--- ...439-lint-parse-xpath-association-paths.mdl | 48 ++++++ mdl/exprcheck/ast.go | 6 + mdl/exprcheck/interfaces.go | 7 + mdl/exprcheck/parser.go | 69 +++++++++ mdl/exprcheck/xpath_mode_test.go | 56 +++++++ mdl/linter/starlark.go | 45 +++++- .../starlark_catalog_mode_guard_test.go | 1 + mdl/linter/starlark_xpath.go | 17 ++- mdl/linter/starlark_xpath_paths_test.go | 139 ++++++++++++++++++ 11 files changed, 405 insertions(+), 24 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-17-parse-xpath-stops-at-association-path.json create mode 100644 mdl-examples/bug-tests/1439-lint-parse-xpath-association-paths.mdl create mode 100644 mdl/exprcheck/xpath_mode_test.go create mode 100644 mdl/linter/starlark_xpath_paths_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-parse-xpath-stops-at-association-path.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-parse-xpath-stops-at-association-path.json new file mode 100644 index 000000000..dc53711d3 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-17-parse-xpath-stops-at-association-path.json @@ -0,0 +1 @@ +{"area": "mdl/linter", "date": "2026-10-17", "symptom": "parse_xpath(\"[Mod.A/Mod.E/Name = 'x' and Mod.A/Mod.E/Code = 'y']\") returns only qname Mod.A; a recursive tree walker fails with \"function called recursively\", so the write-lint-rules skill's count_not example cannot run; matches() has no captures.", "cause": "parse_xpath fed the constraint to exprcheck, the microflow-EXPRESSION parser, where an entity-rooted path does not exist: it read Mod.A as a qname and stopped at '/', and Parse discards the trailing tokens. The rule file ran under starlark.ExecFile's legacy options, which forbid recursion.", "file": "mdl/exprcheck/parser.go (Context.XPath, parseXPathPath), mdl/linter/starlark.go (ExecFileOptions Recursion, find_all), mdl/linter/starlark_xpath.go, .claude/skills/mendix/write-lint-rules/SKILL.md", "insight": "Make the XPath grammar a parse mode, not a global change: in an expression `Mod.A/Mod.E` must stay an E014, and the control test pins that. The skill example was wrong three ways (recursion, `not` is a unary NOT over a paren not a call, Starlark has no sum), and no reader could have found out because it could never run; the guard is a test that extracts the example from the skill and executes it. Starlark's Recursion option keeps its own 100k-frame stop, so enabling it cannot crash the process.", "refs": "ako/mxcli#1439, mendixlabs/mxcli#1541"} diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index f0cf7cbeb..5a882b869 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -12,10 +12,10 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b |---|---| | 25-67 | Rule File Structure | | 68-161 | Available Query Functions | -| 162-600 | Object Properties | -| 601-613 | Helper Functions | -| 614-700 | Common Patterns | -| 701-710 | Validation | +| 162-601 | Object Properties | +| 602-615 | Helper Functions | +| 616-702 | Common Patterns | +| 703-712 | Validation | # Writing Custom Starlark Lint Rules @@ -395,15 +395,15 @@ Returned by `parse_xpath(s)`. Every node has a `kind` field; additional fields d | `kind` | Additional fields | Description | |--------|-------------------|-------------| -| `"bin"` | `op` (string), `left` (expr), `right` (expr) | Binary operator: `=`, `!=`, `<`, `>`, `<=`, `>=`, `and`, `or` | -| `"unary"` | `op` (string), `operand` (expr) | Unary operator: `not`, `-` | +| `"bin"` | `op` (string), `left` (expr), `right` (expr) | Binary operator: `=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR` — the logical ones upper-case | +| `"unary"` | `op` (string), `operand` (expr) | Unary operator: `NOT`, `-`. XPath `not(…)` is a unary `NOT` whose operand is a `paren`, not a `call` | | `"call"` | `name` (string), `args` (list of expr) | Function call, e.g. `contains(…)`, `length(…)` | | `"string"` | `value` (string) | String literal | | `"number"` | `value` (string) | Numeric literal (kept as string to preserve precision) | | `"bool"` | `value` (bool) | `true` or `false` | | `"empty"` | — | Mendix `empty` keyword | | `"variable"` | `name` (string) | `$ParameterName` | -| `"attr_path"` | `variable` (string), `path` (list of string) | `$Obj/Association/Attribute` | +| `"attr_path"` | `variable` (string), `path` (list of string), `predicates` (list of expr) | `$Obj/Association/Attribute`, or an XPath path from the queried entity, `Mod.Assoc/Mod.Entity/Attr`, whose `variable` is `""`. `predicates[i]` is the `[…]` on `path[i]` (`Mod.Assoc/Mod.Entity[Code = 'x']/Name`), kind `"null"` where there is none | | `"qname"` | `module` (string), `name` (string), `sub` (string) | Qualified name, e.g. `MyApp.Status.Active` | | `"paren"` | `inner` (expr) | Parenthesised expression | | `"if"` | `cond` (expr), `then` (expr), `else_` (expr) | If-then-else expression | @@ -413,32 +413,33 @@ Returned by `parse_xpath(s)`. Every node has a `kind` field; additional fields d | `"null"` | — | Nil / missing node | | `"unknown"` | — | Unrecognised AST node type | -**Walking an expr tree:** check `node.kind` and recurse into child fields. Leaf kinds (no child nodes) are: `string`, `number`, `bool`, `empty`, `variable`, `qname`, `constant`, `token`, `recovered`, `null`, `unknown`. +**Walking an expr tree:** check `node.kind` and recurse into child fields — recursion is allowed in lint rules. Leaf kinds (no child nodes) are: `string`, `number`, `bool`, `empty`, `variable`, `qname`, `constant`, `token`, `recovered`, `null`, `unknown`. An `attr_path` has children only in its `predicates`. Example — count `not(…)` calls in an XPath (using `parse_xpath`): ```python def count_not(node): - if node.kind in ("null", "unknown", "recovered", "string", "number", - "bool", "empty", "variable", "qname", "constant", "token"): - return 0 - if node.kind == "call" and node.name == "not": - return 1 + sum([count_not(a) for a in node.args]) - if node.kind == "call": - return sum([count_not(a) for a in node.args]) + if node.kind == "unary": + return (1 if node.op == "NOT" else 0) + count_not(node.operand) if node.kind == "bin": return count_not(node.left) + count_not(node.right) - if node.kind == "unary": - return count_not(node.operand) if node.kind == "paren": return count_not(node.inner) if node.kind == "if": return count_not(node.cond) + count_not(node.then) + count_not(node.else_) + children = [] + if node.kind == "call": + children = node.args if node.kind == "attr_path": - return 0 - return 0 + children = node.predicates + n = 0 + for c in children: + n += count_not(c) + return n ``` +(Starlark has no `sum`; add in a loop.) + ### attribute | Property | Type | Example | |----------|------|---------| @@ -608,6 +609,7 @@ Returned by `languages()`, one per language enabled in the project settings. A p | `is_pascal_case(s)` | Returns True if string is PascalCase | | `is_camel_case(s)` | Returns True if string is camelCase | | `matches(s, pattern)` | Returns True if string matches regex | +| `find_all(s, pattern)` | Every match of the regex in `s`: a tuple of the capture groups per match, or the matched string when the pattern has no groups | | `get_option(key, default?)` | The rule's option `key` from the `options:` block under its rule ID in `.claude/lint-config.yaml`, or `default` (`None` if omitted) when unset | | `struct(**kwargs)` | Build an ad-hoc struct, e.g. `struct(name="x", count=1)`, to group values inside a rule | diff --git a/mdl-examples/bug-tests/1439-lint-parse-xpath-association-paths.mdl b/mdl-examples/bug-tests/1439-lint-parse-xpath-association-paths.mdl new file mode 100644 index 000000000..bf57e877f --- /dev/null +++ b/mdl-examples/bug-tests/1439-lint-parse-xpath-association-paths.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1439 (mendixlabs/mxcli#1541) — parse_xpath stopped at association +-- paths, and lint rules could not recurse +-- ============================================================================ +-- +-- Reported: +-- - parse_xpath("[Mod.A/Mod.E/Name = 'x' and Mod.A/Mod.E/Code = 'y']") +-- returns only qname Mod.A; the comparison and the rest are lost. +-- - A rule that walks the tree with a recursive function fails with +-- "function called recursively"; the walking example in the +-- write-lint-rules skill (count_not) is recursive and cannot run. +-- - matches() returns only true/false, without captures. +-- +-- parse_xpath now parses in XPath mode: a path from the queried entity is an +-- attr_path with variable "" and a predicates list. Recursion is enabled for +-- rules, the skill example is fixed (it also matched `not` as a call and used +-- `sum`, which Starlark lacks) and runs as a test, and find_all(s, pattern) +-- returns the capture groups. +-- +-- The assertions are Go tests (mdl/linter/starlark_xpath_paths_test.go, +-- mdl/exprcheck/xpath_mode_test.go). To reproduce by hand, run this script and +-- lint a rule that renders parse_xpath(x.xpath_expression) for every +-- xpath_expressions() row of module X1439 with a recursive function: both +-- comparisons and both paths must come back. +-- ============================================================================ + +create module X1439; + +create persistent entity X1439.Customer ( + Name: string(100), + Code: string(20) +); + +create persistent entity X1439.Order ( + Number: string(20) +); + +create association X1439.Order_Customer from X1439.Order to X1439.Customer; + +create microflow X1439.GetOrders () +returns list of X1439.Order as $orders +begin + retrieve $orders from X1439.Order + where X1439.Order_Customer/X1439.Customer/Name = 'x' + and X1439.Order_Customer/X1439.Customer/Code = 'y'; + return $orders; +end; diff --git a/mdl/exprcheck/ast.go b/mdl/exprcheck/ast.go index f13a7be04..14d089ccb 100644 --- a/mdl/exprcheck/ast.go +++ b/mdl/exprcheck/ast.go @@ -47,8 +47,14 @@ type VariableExpr struct { type AttributePathExpr struct { baseNode + // Variable is empty for an XPath path that starts at the queried entity + // (`Mod.Assoc/Mod.Entity/Attr`), which only an XPath-mode parse produces. Variable string Path []string + // Predicates is nil unless a segment carries an XPath predicate + // (`Mod.Assoc/Mod.Entity[Code = 'x']/Name`); then it has one entry per + // Path segment, nil where that segment has none. + Predicates []RobustExpr } type QNameExpr struct { diff --git a/mdl/exprcheck/interfaces.go b/mdl/exprcheck/interfaces.go index 831cd5f74..c727cdd62 100644 --- a/mdl/exprcheck/interfaces.go +++ b/mdl/exprcheck/interfaces.go @@ -22,6 +22,13 @@ type Context struct { Catalog CatalogReader // nil → semantic checks disabled Slots SlotResolver // nil → slot-kind checks disabled Entities EntityScope // nil → attribute paths stay KindUnknown + + // XPath parses the source as an XPath constraint body rather than a + // microflow expression: a path may start at the queried entity + // (`Mod.Assoc/Mod.Entity/Attr`, no variable) and any segment may carry a + // `[...]` predicate. Neither is valid in an expression, so it is off + // everywhere but parse_xpath (ako/mxcli#1439). + XPath bool } // IsSemanticEnabled reports whether Catalog and Slots are both wired so that diff --git a/mdl/exprcheck/parser.go b/mdl/exprcheck/parser.go index b75c855e2..5cb122837 100644 --- a/mdl/exprcheck/parser.go +++ b/mdl/exprcheck/parser.go @@ -492,8 +492,14 @@ func parseIdentLed(s *Stream, ctx Context) (RobustExpr, []Hint) { if s.Peek().Kind == TokLParen { return parseQualifiedCall(s, ctx, t.Pos, name+"."+n2) } + if ctx.XPath && startsXPathPathStep(s) { + return parseXPathPath(s, ctx, t.Pos, "", []string{name + "." + n2}) + } return &QNameExpr{baseNode: baseNode{P: t.Pos}, Module: name, Name: n2}, nil } + if ctx.XPath && startsXPathPathStep(s) { + return parseXPathPath(s, ctx, t.Pos, "", []string{name}) + } // Bare records that this came from a plain identifier rather than // `$Name`. The two collapse to the same node, and telling them apart is // what makes the bare-word rule possible. @@ -531,6 +537,9 @@ func parseQualifiedCall(s *Stream, ctx Context, pos Position, name string) (Robu func parseDollar(s *Stream, ctx Context) (RobustExpr, []Hint) { t := s.Consume() name := strings.TrimPrefix(t.Text, "$") + if ctx.XPath && s.Peek().Kind == TokSlash { + return parseXPathPath(s, ctx, t.Pos, name, nil) + } if s.Peek().Kind != TokSlash { return &VariableExpr{baseNode: baseNode{P: t.Pos}, Name: name}, nil } @@ -554,6 +563,66 @@ func parseDollar(s *Stream, ctx Context) (RobustExpr, []Hint) { return &AttributePathExpr{baseNode: baseNode{P: t.Pos}, Variable: name, Path: path}, nil } +// startsXPathPathStep reports whether the next token continues an XPath path: +// a `/` to the next segment, or a `[` opening a predicate on this one. The +// lexer has no bracket token (`[` is only meaningful as `[%token%]` in an +// expression), so a bare bracket arrives as a one-character TokError. +func startsXPathPathStep(s *Stream) bool { + t := s.Peek() + return t.Kind == TokSlash || isBracket(t, "[") +} + +func isBracket(t Token, b string) bool { return t.Kind == TokError && t.Text == b } + +// parseXPathPath reads the rest of an XPath path whose first segments (if +// any) are already in path: `/`-separated segments, each a possibly +// qualified name, each optionally followed by a `[...]` predicate. +func parseXPathPath(s *Stream, ctx Context, pos Position, variable string, path []string) (RobustExpr, []Hint) { + var preds []RobustExpr + var hs []Hint + hasPred := false + predicate := func() { + var pred RobustExpr + if isBracket(s.Peek(), "[") { + s.Consume() + var h []Hint + pred, h = parseOr(s, ctx) + hs = append(hs, h...) + if isBracket(s.Peek(), "]") { + s.Consume() + } + hasPred = true + } + preds = append(preds, pred) + } + if len(path) > 0 { + // Only the last segment already read can still take a predicate. + preds = make([]RobustExpr, len(path)-1) + predicate() + } + for s.Peek().Kind == TokSlash { + s.Consume() + if s.Peek().Kind != TokIdent { + break + } + seg := s.Consume().Text + for s.Peek().Kind == TokDot { + s.Consume() + if s.Peek().Kind != TokIdent { + break + } + seg += "." + s.Consume().Text + } + path = append(path, seg) + predicate() + } + node := &AttributePathExpr{baseNode: baseNode{P: pos}, Variable: variable, Path: path} + if hasPred { + node.Predicates = preds + } + return node, hs +} + func parseConstantRef(s *Stream, ctx Context, p Position) (RobustExpr, []Hint) { if s.Peek().Kind != TokIdent { return &RecoveredExpr{baseNode: baseNode{P: p}, SourceFragment: "@", Reason: "expected qualified name after '@'"}, nil diff --git a/mdl/exprcheck/xpath_mode_test.go b/mdl/exprcheck/xpath_mode_test.go new file mode 100644 index 000000000..80fffb790 --- /dev/null +++ b/mdl/exprcheck/xpath_mode_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package exprcheck + +import ( + "reflect" + "testing" +) + +// An XPath path from the queried entity, with a predicate on one segment, +// parses whole in XPath mode (ako/mxcli#1439). Before, the parse stopped at +// the first `/` and kept only `Mod.A`. +func TestXPathModeParsesEntityRootedPaths(t *testing.T) { + ctx := NewSyntaxContext("", "") + ctx.XPath = true + expr, hs := NewParser().Parse(`Mod.A/Mod.E[Code = 'y']/Name = 'x'`, ctx) + for _, h := range hs { + if h.Code == "E014" { + t.Fatalf("trailing tokens left: %+v", h) + } + } + bin, ok := expr.(*BinExpr) + if !ok { + t.Fatalf("root = %T, want *BinExpr", expr) + } + path, ok := bin.L.(*AttributePathExpr) + if !ok { + t.Fatalf("left = %T, want *AttributePathExpr", bin.L) + } + if path.Variable != "" || !reflect.DeepEqual(path.Path, []string{"Mod.A", "Mod.E", "Name"}) { + t.Errorf("path = %q %v", path.Variable, path.Path) + } + if len(path.Predicates) != 3 || path.Predicates[0] != nil || path.Predicates[2] != nil { + t.Fatalf("predicates = %#v, want one on segment 1", path.Predicates) + } + if _, ok := path.Predicates[1].(*BinExpr); !ok { + t.Errorf("predicate on Mod.E = %T, want *BinExpr", path.Predicates[1]) + } +} + +// Control: a microflow expression is not XPath. `Mod.A/Mod.E` is not a valid +// expression, so the expression parser must keep reporting it, not quietly +// accept it as a path. +func TestExpressionModeKeepsRejectingEntityRootedPaths(t *testing.T) { + expr, hs := NewParser().Parse(`Mod.A/Mod.E`, NewSyntaxContext("", "")) + if _, ok := expr.(*QNameExpr); !ok { + t.Errorf("root = %T, want *QNameExpr", expr) + } + found := false + for _, h := range hs { + found = found || h.Code == "E014" + } + if !found { + t.Errorf("no E014 for an entity-rooted path in an expression: %+v", hs) + } +} diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index b2d997b3f..fe7832952 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -13,6 +13,7 @@ import ( "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" + "go.starlark.net/syntax" "github.com/mendixlabs/mxcli/mdl/exprcheck" ) @@ -295,7 +296,14 @@ func LoadStarlarkRule(path string) (*StarlarkRule, error) { Name: filepath.Base(path), } - globals, err := starlark.ExecFile(thread, path, data, predeclared) + // Recursion is on: an expression tree from parse_xpath is walked + // recursively — the write-lint-rules skill's own example does — and with + // Starlark's default that failed as "function called recursively" + // (ako/mxcli#1439). The interpreter still stops a runaway rule at 100,000 + // frames with "Starlark stack overflow", which is a rule failure, not a crash. + opts := syntax.LegacyFileOptions() + opts.Recursion = true + globals, err := starlark.ExecFileOptions(opts, thread, path, data, predeclared) if err != nil { return nil, fmt.Errorf("failed to execute Starlark file: %w", err) } @@ -431,6 +439,7 @@ func (r *StarlarkRule) buildPredeclared() starlark.StringDict { "is_pascal_case": starlark.NewBuiltin("is_pascal_case", builtinIsPascalCase), "is_camel_case": starlark.NewBuiltin("is_camel_case", builtinIsCamelCase), "matches": starlark.NewBuiltin("matches", builtinMatches), + "find_all": starlark.NewBuiltin("find_all", builtinFindAll), // Struct constructor (from starlarkstruct) "struct": starlark.NewBuiltin("struct", starlarkstruct.Make), @@ -1344,7 +1353,9 @@ func (r *StarlarkRule) builtinParseXPath(_ *starlark.Thread, _ *starlark.Builtin inner := stripXPathBrackets(string(s)) parser := exprcheck.NewParser() - ast, _ := parser.Parse(inner, exprcheck.NewSyntaxContext("", "")) + ctx := exprcheck.NewSyntaxContext("", "") + ctx.XPath = true + ast, _ := parser.Parse(inner, ctx) return robustExprToStarlark(ast), nil } @@ -1460,6 +1471,36 @@ func builtinMatches(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple return starlark.False, nil } +// builtinFindAll returns every match of pattern in s: per match, a tuple of +// its capture groups, or the matched text when the pattern has none — the +// shape of Python's re.findall. matches() answers only yes or no, so a rule +// could not read the part of a name it had matched (ako/mxcli#1439). +func builtinFindAll(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var s, pattern starlark.String + if err := starlark.UnpackArgs("find_all", args, kwargs, "s", &s, "pattern", &pattern); err != nil { + return nil, err + } + + re, err := regexp.Compile(string(pattern)) + if err != nil { + return nil, fmt.Errorf("invalid regex pattern: %w", err) + } + + var out []starlark.Value + for _, m := range re.FindAllStringSubmatch(string(s), -1) { + if len(m) == 1 { + out = append(out, starlark.String(m[0])) + continue + } + groups := make(starlark.Tuple, len(m)-1) + for i, g := range m[1:] { + groups[i] = starlark.String(g) + } + out = append(out, groups) + } + return starlark.NewList(out), nil +} + // RuleLoadFailure is a .star file that was found but produced no rule. type RuleLoadFailure struct { Path string diff --git a/mdl/linter/starlark_catalog_mode_guard_test.go b/mdl/linter/starlark_catalog_mode_guard_test.go index dbea4304d..17833799e 100644 --- a/mdl/linter/starlark_catalog_mode_guard_test.go +++ b/mdl/linter/starlark_catalog_mode_guard_test.go @@ -93,6 +93,7 @@ var builtinModes = map[string]builtinProbe{ "is_pascal_case": {CatalogFast, ""}, "is_camel_case": {CatalogFast, ""}, "matches": {CatalogFast, ""}, + "find_all": {CatalogFast, ""}, "struct": {CatalogFast, ""}, "get_option": {CatalogFast, ""}, } diff --git a/mdl/linter/starlark_xpath.go b/mdl/linter/starlark_xpath.go index ca1637fe0..d26eb0ff7 100644 --- a/mdl/linter/starlark_xpath.go +++ b/mdl/linter/starlark_xpath.go @@ -103,10 +103,21 @@ func robustExprToStarlark(expr exprcheck.RobustExpr) starlark.Value { for i, p := range n.Path { pathParts[i] = starlark.String(p) } + // One predicate per segment, kind "null" where it has none, so a rule + // can index predicates[i] alongside path[i]. + preds := make([]starlark.Value, len(n.Path)) + for i := range n.Path { + var p exprcheck.RobustExpr + if i < len(n.Predicates) { + p = n.Predicates[i] + } + preds[i] = robustExprToStarlark(p) + } return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{ - "kind": starlark.String("attr_path"), - "variable": starlark.String(n.Variable), - "path": starlark.NewList(pathParts), + "kind": starlark.String("attr_path"), + "variable": starlark.String(n.Variable), + "path": starlark.NewList(pathParts), + "predicates": starlark.NewList(preds), }) case *exprcheck.QNameExpr: return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{ diff --git a/mdl/linter/starlark_xpath_paths_test.go b/mdl/linter/starlark_xpath_paths_test.go new file mode 100644 index 000000000..739e5d348 --- /dev/null +++ b/mdl/linter/starlark_xpath_paths_test.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// render walks a parse_xpath tree RECURSIVELY, the way the write-lint-rules +// skill's count_not example does — which failed with "function called +// recursively" before recursion was enabled (ako/mxcli#1439). +const renderXPath = ` +def render(n): + if n.kind == "bin": + return "(" + render(n.left) + " " + n.op + " " + render(n.right) + ")" + if n.kind == "attr_path": + segs = [] + for i, seg in enumerate(n.path): + p = n.predicates[i] + segs.append(seg if p.kind == "null" else seg + "[" + render(p) + "]") + return ("$" + n.variable + "/" if n.variable else "") + "/".join(segs) + if n.kind == "string": + return "'" + n.value + "'" + if n.kind == "variable": + return n.name + if n.kind == "qname": + return n.module + "." + n.name + if n.kind == "unary": + return n.op + " " + render(n.operand) + if n.kind == "paren": + return "(" + render(n.inner) + ")" + if n.kind == "call": + return n.name + "(" + ", ".join([render(a) for a in n.args]) + ")" + return "<" + n.kind + ">" +` + +func lintCtx(t *testing.T) *linter.LintContext { + t.Helper() + cat, err := catalog.NewFromFile(filepath.Join(t.TempDir(), "cat.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cat.Close() }) + return linter.NewLintContext(cat, nil) +} + +// "parse_xpath("[Mod.A/Mod.E/Name = 'x' and Mod.A/Mod.E/Code = 'y']") returns +// only qname Mod.A; the comparison and the rest are lost. Plain attributes +// parse ([Name = 'x'] gives bin), association paths do not." (ako/mxcli#1439) +func TestParseXPathAssociationPaths(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + // Control: a plain attribute parsed before the fix too. + {`[Name = 'x']`, `(Name = 'x')`}, + {`[Mod.A/Mod.E/Name = 'x' and Mod.A/Mod.E/Code = 'y']`, + `((Mod.A/Mod.E/Name = 'x') AND (Mod.A/Mod.E/Code = 'y'))`}, + {`[Mod.A/Mod.E[Code = 'y']/Name = 'x']`, `(Mod.A/Mod.E[(Code = 'y')]/Name = 'x')`}, + {`[Mod.Order_Customer = $Customer]`, `(Mod.Order_Customer = Customer)`}, + {`[not(Mod.A/Mod.E)]`, `NOT (Mod.A/Mod.E)`}, + {`[$currentObject/Mod.A/Name = 'x']`, `($currentObject/Mod.A/Name = 'x')`}, + } { + vs, _ := runSrc(t, lintCtx(t), renderXPath+` +def check(): + return [violation(message = render(parse_xpath(`+"\""+escapeStarlark(tc.in)+"\""+`)))] +`) + if len(vs) != 1 || vs[0].Message != tc.want { + var got []string + for _, v := range vs { + got = append(got, v.Message) + } + t.Errorf("parse_xpath(%s) renders %v, want %q", tc.in, got, tc.want) + } + } +} + +func escapeStarlark(s string) string { + out := "" + for _, r := range s { + if r == '"' || r == '\\' { + out += "\\" + } + out += string(r) + } + return out +} + +// "matches() returns only true/false, without captures" (ako/mxcli#1439). +// find_all returns every match, each as its capture groups (or the whole match +// when the pattern has none). +func TestFindAllReturnsCaptures(t *testing.T) { + vs, _ := runSrc(t, lintCtx(t), ` +def check(): + a = find_all("ACT_Order_Save, ACT_Line_Delete", "ACT_([A-Za-z]+)_([A-Za-z]+)") + b = find_all("x1 y22", "[0-9]+") + c = find_all("none", "[0-9]+") + return [violation(message = repr(a) + " " + repr(b) + " " + repr(c))] +`) + want := `[("Order", "Save"), ("Line", "Delete")] ["1", "22"] []` + if len(vs) != 1 || vs[0].Message != want { + t.Errorf("find_all = %+v, want %q", vs, want) + } +} + +// TestSkillCountNotExampleRuns runs the write-lint-rules skill's walking +// example as written. It could not run at all (recursion was disabled), and +// it matched `not` as a call although parse_xpath returns a unary NOT — so +// even with recursion it counted nothing (ako/mxcli#1439). +func TestSkillCountNotExampleRuns(t *testing.T) { + doc, err := os.ReadFile("../../.claude/skills/mendix/write-lint-rules/SKILL.md") + if err != nil { + t.Fatal(err) + } + const marker = "Example — count `not(…)` calls in an XPath" + i := strings.Index(string(doc), marker) + if i < 0 { + t.Fatalf("skill has no %q section", marker) + } + rest := string(doc)[i:] + start := strings.Index(rest, "```python\n") + end := strings.Index(rest[start+10:], "```") + if start < 0 || end < 0 { + t.Fatal("no python block after the example heading") + } + example := rest[start+10 : start+10+end] + + vs, _ := runSrc(t, lintCtx(t), example+` +def check(): + n = count_not(parse_xpath("[not(Mod.A/Mod.E) and Mod.A/Mod.E[not(Code = 'x')]/Name = 'y' and Active]")) + return [violation(message = str(n))] +`) + if len(vs) != 1 || vs[0].Message != "2" { + t.Errorf("count_not = %+v, want 2", vs) + } +} From b144644a69438cba1375ace8ad3f787c53786af9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:49:03 +0000 Subject: [PATCH 16/22] fix: describe module prints grants after the associations they name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describe module … with all` printed each entity's access rules with the entity, ahead of the module's associations. A grant naming an association the entity owns was then refused on replay — "entity ModA.EntA has no member(s) EntA_EntB; grant only names members of the entity or of an entity it inherits from" — after `check` had passed the same script (mendixlabs/mxcli#1409, ako/mxcli#1315). describe module now prints entities, then associations, then every grant. check's grant pass resolved members against the whole script and skipped script-declared entities, so it could not see the ordering exec enforces; it now reports a grant naming an association created further down, with the reorder hint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wx8dgxQm9qE8ZsMxwRweXj --- ...cribe-module-grant-before-association.json | 1 + CHANGELOG.md | 1 + .../1409-grant-after-association.mdl | 43 +++++++++ mdl/executor/cmd_entities_describe.go | 13 ++- mdl/executor/cmd_modules.go | 19 +++- .../grant_association_order_1409_test.go | 92 +++++++++++++++++++ mdl/executor/validate_grant_refs.go | 53 ++++++++++- 7 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-module-grant-before-association.json create mode 100644 mdl-examples/bug-tests/1409-grant-after-association.mdl create mode 100644 mdl/executor/grant_association_order_1409_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-module-grant-before-association.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-module-grant-before-association.json new file mode 100644 index 000000000..1a3d02334 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-module-grant-before-association.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "Replaying `describe` output into a second app: `Created entity: ModA.EntA` then `Error: entity ModA.EntA has no member(s) EntA_EntB; grant only names members of the entity or of an entity it inherits from`, after `check` printed \"Check passed!\". Seen in the field on `describe module` of EchoConnector and NativeNote", "cause": "`describe module … with all` called `describeEntity` per entity, which prints the entity's grants inline, and only then the associations — so a grant naming an association it owns came before the `create association`. exec resolves a grant's member list when it writes the grant. `check`'s grant pass resolved members against `g.whole.associations` (the whole script) and skipped script-declared entities altogether, so it accepted the forward reference", "file": "`mdl/executor/cmd_modules.go` (`describeModule`), `mdl/executor/cmd_entities_describe.go` (`describeEntityWith`), `mdl/executor/validate_grant_refs.go` (`memberProblem`, `laterAssociationProblem`)", "insight": "An association is a member of its FROM entity but a statement of its own, so any emitter that prints per-entity (grants with the entity) can order a member after its use. Grep for other `whole`-vs-`sc` reads in check passes: resolving against the whole script is exactly what makes check blind to ordering that exec enforces. The grant check keeps skipping a declared entity's attribute list (that would be a second copy of exec's) but an association is checked anyway, because its position is the script's choice. Controls: the new pedapp tests fail on the old code with the reported message (describe replay) and with an empty check report (agreement test). `describe entity` alone still prints its grants inline — concatenating `describe entity` before `describe association` by hand is now caught by `check -p` instead", "refs": ["#1315", "mendixlabs/mxcli#1409"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8870c439b..7d6313b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`describe module` prints an entity's grants after the associations they name** (mendixlabs/mxcli#1409) — an entity's grant naming one of its associations was printed with the entity, ahead of the association, so replaying the output stopped with "entity ModA.EntA has no member(s) EntA_EntB", leaving the entity without its association or access. `check` passed the same script; it now reports a grant naming an association created further down the script, with the reorder. - **Workflow user tasks keep "Auto-assign when targeting results in one user"** (mendixlabs/mxcli#1547) — every single- and multi-user task was written with `AutoAssignSingleTargetUser = false`, MDL had no spelling for it and `describe workflow` did not print it, so `create or modify workflow` turned off a box ticked in Studio Pro. When the targeting then yielded one user, the task was not assigned to them and completing it from a microflow failed at runtime with "You can't complete this user task, it is not assigned to you"; `check` and `mx check` were silent. The new clause `auto assign single user` (on `user task` and `multi user task`) writes it, `describe` prints it when set, and a `create or modify` or `replace activity` that does not restate a stored one is refused, like `await all users`. Omitted, it is off, which is what Studio Pro 11.14 stores for a new task. The MCP backend sent `false` too and now sends the task's value. ## [0.27.0] - 2026-10-15 diff --git a/mdl-examples/bug-tests/1409-grant-after-association.mdl b/mdl-examples/bug-tests/1409-grant-after-association.mdl new file mode 100644 index 000000000..ade8bcba8 --- /dev/null +++ b/mdl-examples/bug-tests/1409-grant-after-association.mdl @@ -0,0 +1,43 @@ +mdl 1; +-- ============================================================================ +-- Bug #1409 — a grant naming an association member is refused when it +-- precedes the association. +-- +-- `describe module` printed each entity's grant with the entity, ahead of the +-- associations, so replaying its output into a second app stopped with +-- +-- Error: entity ModA.EntA has no member(s) EntA_EntB; grant only names +-- members of the entity or of an entity it inherits from +-- +-- after `check` had said "Check passed!". `describe module` now prints every +-- grant after every association, and `check -p` reports a grant naming an +-- association created further down ("… yet — the association is created later +-- in this script; move the grant below that create statement"). +-- +-- mxcli exec this-file -p app.mpr +-- mxcli -p app.mpr -c "describe module Issue1409 with all" > out.mdl +-- before: the grant line comes before `create or modify association` +-- after: it comes after, and out.mdl replays into an app holding only +-- the module and its role +-- +-- This is the order describe now prints. Moving the grant above the +-- `create association` is the reported shape: `check -p` refuses it. +-- ============================================================================ + +create module Issue1409; +create module role Issue1409.Admin; + +@position(100, 100) +create persistent entity Issue1409.EntA ( + Name: string(50) +); + +@position(360, 100) +create persistent entity Issue1409.EntB ( + Name: string(50) +); + +create association Issue1409.EntA_EntB +from Issue1409.EntA to Issue1409.EntB; + +grant read (Name, EntA_EntB), write (Name) on entity Issue1409.EntA to Issue1409.Admin; diff --git a/mdl/executor/cmd_entities_describe.go b/mdl/executor/cmd_entities_describe.go index 1f5f642c9..da888c430 100644 --- a/mdl/executor/cmd_entities_describe.go +++ b/mdl/executor/cmd_entities_describe.go @@ -215,6 +215,15 @@ func listEntity(ctx *ExecContext, name *ast.QualifiedName) error { // describeEntity handles DESCRIBE ENTITY command. func describeEntity(ctx *ExecContext, name ast.QualifiedName) error { + return describeEntityWith(ctx, name, true) +} + +// describeEntityWith describes an entity, with its access rules as grants when +// withGrants is set. `describe module` leaves them out and prints them after +// the module's associations, which a grant may name: replayed in entity order, +// such a grant was refused because the association did not exist yet +// (mendixlabs/mxcli#1409). +func describeEntityWith(ctx *ExecContext, name ast.QualifiedName, withGrants bool) error { module, err := findModule(ctx, name.Module) if err != nil { return err @@ -457,7 +466,9 @@ func describeEntity(ctx *ExecContext, name ast.QualifiedName) error { outputEntityValidationRules(ctx, entity, name.Module, name.Name, attrNames) // Output access rule GRANT statements - outputEntityAccessGrants(ctx, entity, name.Module, name.Name) + if withGrants { + outputEntityAccessGrants(ctx, entity, name.Module, name.Name) + } return nil } diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index bfe5fe6e3..185e748ca 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -688,17 +688,23 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { run.emitKinds(moduleDocKindsBeforeDomainModel) // Entities in dependency order (base entities before derived entities), - // then associations once every entity is defined. + // then associations once every entity is defined, then the entities' + // access rules — a grant names the associations its entity owns, and exec + // refuses one naming an association that does not exist yet + // (mendixlabs/mxcli#1409). dm, err := ctx.Backend.GetDomainModel(targetModule.ID) if err != nil { run.fail("domain model "+moduleName, err) } else if dm != nil { - for _, entity := range sortEntitiesByGeneralization(dm.Entities, moduleName) { + entities := sortEntitiesByGeneralization(dm.Entities, moduleName) + described := make([]*domainmodel.Entity, 0, len(entities)) + for _, entity := range entities { qn := ast.QualifiedName{Module: moduleName, Name: entity.Name} - if err := describeEntity(ctx, qn); err != nil { + if err := describeEntityWith(ctx, qn, false); err != nil { run.fail("entity "+qn.String(), err) continue } + described = append(described, entity) fmt.Fprintln(ctx.Output) } // Cross-module associations are stored in a separate collection but are @@ -718,6 +724,13 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { } fmt.Fprintln(ctx.Output) } + for _, entity := range described { + if len(entity.AccessRules) == 0 { + continue + } + outputEntityAccessGrants(ctx, entity, moduleName, entity.Name) + fmt.Fprintln(ctx.Output) + } } run.emitKinds(moduleDocKindsAfterDomainModel) diff --git a/mdl/executor/grant_association_order_1409_test.go b/mdl/executor/grant_association_order_1409_test.go new file mode 100644 index 000000000..4c45a8f7c --- /dev/null +++ b/mdl/executor/grant_association_order_1409_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1409 (ako/mxcli#1315): an entity's grant naming one of its +// associations was printed with the entity, ahead of the association, so the +// describe output was refused on replay: +// +// Created entity: ModA.EntA +// Error: entity ModA.EntA has no member(s) EntA_EntB; grant only names +// members of the entity or of an entity it inherits from +// +// after `check` had said "Check passed!". + +// describeOrderSetup is the reported module: an entity whose grant names the +// association it owns. +const describeOrderSetup = `mdl 1; +create module ModA; +create persistent entity ModA.EntA (Name: String(50)); +create persistent entity ModA.EntB (Name: String(50)); +create association ModA.EntA_EntB from ModA.EntA to ModA.EntB; +create module role ModA.Admin; +grant read (Name, EntA_EntB), write (Name) on entity ModA.EntA to ModA.Admin;` + +// `describe module … with all` prints every grant after every association, so +// its output replays into a project that has only the module and its role. +func TestDescribeModule_GrantFollowsTheAssociationItNames(t *testing.T) { + src, out, _ := openPedAppCopy(t) + if err := agreeExec(t, src, describeOrderSetup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + out.Reset() + if err := src.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeModule, Name: ast.QualifiedName{Module: "ModA"}, WithAll: true}); err != nil { + t.Fatalf("describe module: %v\n%s", err, out.String()) + } + described := out.String() + grant := strings.Index(described, "on entity ModA.EntA to ModA.Admin") + assoc := strings.Index(described, "association ModA.EntA_EntB") + if grant < 0 || assoc < 0 { + t.Fatalf("describe module lost the grant or the association:\n%s", described) + } + if grant < assoc { + t.Errorf("the grant naming EntA_EntB is printed before the association:\n%s", described) + } + + // Replay into a second project holding only the module and the role. + dst, dstOut, _ := openPedAppCopy(t) + if err := agreeExec(t, dst, "mdl 1;\ncreate module ModA;\ncreate module role ModA.Admin;"); err != nil { + t.Fatalf("target setup: %v\n%s", err, dstOut.String()) + } + if err := agreeExec(t, dst, languageHeader.ReplaceAllString(described, "")); err != nil { + t.Fatalf("describe module output does not replay: %v\n%s", err, described) + } +} + +// check says what exec says: a grant naming an association a later statement +// creates is refused with the reorder hint, on an entity the script declares as +// well as on a stored one. The control is the order describe now prints. +func TestCheckExecAgree_GrantNamesAssociationCreatedLater(t *testing.T) { + const entities = `mdl 1; +create module ModA; +create module role ModA.Admin; +create persistent entity ModA.EntA (Name: String(50)); +create persistent entity ModA.EntB (Name: String(50)); +` + const grant = "grant read (EntA_EntB, Name), write (Name) on entity ModA.EntA to ModA.Admin;\n" + const assoc = "create association ModA.EntA_EntB from ModA.EntA to ModA.EntB;\n" + + t.Run("grant above the association (the reported shape)", func(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + checkSays := strings.Join(agreeCheck(t, exec, dir, entities+grant+assoc), "\n") + for _, want := range []string{"has no member(s) EntA_EntB", "created later in this script"} { + if !strings.Contains(checkSays, want) { + t.Errorf("check must report %q, reported:\n%s", want, checkSays) + } + } + if err := agreeExec(t, exec, entities+grant+assoc); err == nil || !strings.Contains(err.Error(), "has no member(s) EntA_EntB") { + t.Errorf("exec must refuse the grant, got: %v", err) + } + }) + t.Run("control: grant below the association", func(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + assertAgree(t, exec, dir, entities+assoc+grant, "") + }) +} diff --git a/mdl/executor/validate_grant_refs.go b/mdl/executor/validate_grant_refs.go index 309f5ff44..5374a3739 100644 --- a/mdl/executor/validate_grant_refs.go +++ b/mdl/executor/validate_grant_refs.go @@ -539,7 +539,7 @@ type grantXPathResolver struct { func (g *grantResolver) memberProblem(qn ast.QualifiedName, rights []ast.EntityAccessRight) string { name := qn.String() - if g.whole.entities[name] || g.whole.indirectEntities[name] || g.whole.alteredEntities[name] { + if g.whole.indirectEntities[name] { return "" } var readMembers, writeMembers []string @@ -554,22 +554,69 @@ func (g *grantResolver) memberProblem(qn ast.QualifiedName, rights []ast.EntityA if len(readMembers) == 0 && len(writeMembers) == 0 { return "" } + if g.whole.entities[name] || g.whole.alteredEntities[name] { + return g.laterAssociationProblem(qn, readMembers, writeMembers) + } members, ok := grantableMemberNames(g.ctx, qn) if !ok { return "" } - // An association the script declares becomes a member exec will find. - for assoc := range g.whole.associations { + // An association created above the grant is a member exec will find; one + // created below it is not yet (laterAssociationProblem). + for assoc := range g.sc.associations { members[assoc] = true } unknown := unmatchedGrantMembers(readMembers, writeMembers, members) if len(unknown) == 0 { return "" } + if later := g.laterAssociationProblem(qn, readMembers, writeMembers); later != "" { + var rest []string + for _, m := range unknown { + if g.whole.associations[m] == "" { + rest = append(rest, m) + } + } + if len(rest) == 0 { + return later + } + return fmt.Sprintf("%s; and it has no member(s) %s at all", later, strings.Join(rest, ", ")) + } return fmt.Sprintf("entity %s has no member(s) %s; grant only names members of the entity or of an entity it inherits from", name, strings.Join(unknown, ", ")) } +// laterAssociationProblem reports the members a grant names that are +// associations a LATER statement creates. exec resolves the member list when +// it writes the grant, so it refuses them — and `describe` used to print an +// entity's grant ahead of the associations it names, which `check` passed +// because it resolved them against the whole script (mendixlabs/mxcli#1409). +// +// It is the one member check made for an entity the script declares or +// alters: the rest of such an entity's member list would be a second, drifting +// copy of exec's, but an association is a statement of its own whose position +// the script decides. +func (g *grantResolver) laterAssociationProblem(qn ast.QualifiedName, readMembers, writeMembers []string) string { + stored, _ := grantableMemberNames(g.ctx, qn) + var later []string + seen := map[string]bool{} + for _, list := range [][]string{readMembers, writeMembers} { + for _, m := range list { + if seen[m] || stored[m] || g.sc.associations[m] != "" || g.whole.associations[m] == "" { + continue + } + seen[m] = true + later = append(later, m) + } + } + if len(later) == 0 { + return "" + } + sort.Strings(later) + return fmt.Sprintf("entity %s has no member(s) %s yet — the association is created later in this script; "+ + "move the grant below that create statement", qn.String(), strings.Join(later, ", ")) +} + // grantableMemberNames is the set of member names execGrantEntityAccess builds // a MemberAccess for (or accepts, for the audit members): the entity's own and // inherited attributes, its audit members, the associations it owns — FROM From 3f82b091021f2baf29dbf6657de368c95a6c6b60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:49:03 +0000 Subject: [PATCH 17/22] fix: import from mapping imports XML for an XML schema or web service mapping ImportMappingCall.ContentType was always written as "Json", so re-executing a microflow whose `import from mapping` uses a mapping built on a web service operation failed mx check with "The selected import mapping cannot handle content type JSON." until the activity was opened in Studio Pro (mendixlabs/mxcli#1232, ako/mxcli#1290). The builder derives the content type from the mapping's source (XML schema or web service -> Xml, otherwise Json), the reader carries a stored one so a rewrite keeps it, and REST call mapping result handling uses the same field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wx8dgxQm9qE8ZsMxwRweXj --- ...ping-call-content-type-hardcoded-json.json | 1 + CHANGELOG.md | 1 + .../1232-import-mapping-xml-content-type.mdl | 52 +++++++++++ ...microflow_import_content_type_1232_test.go | 92 +++++++++++++++++++ .../modelsdk/microflow_read_actions.go | 1 + mdl/backend/modelsdk/microflow_write.go | 4 +- mdl/executor/cmd_microflows_builder_calls.go | 23 ++++- .../import_mapping_content_type_1232_test.go | 51 ++++++++++ sdk/microflows/microflows_actions.go | 7 ++ 9 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-17-import-mapping-call-content-type-hardcoded-json.json create mode 100644 mdl-examples/bug-tests/1232-import-mapping-xml-content-type.mdl create mode 100644 mdl/backend/modelsdk/microflow_import_content_type_1232_test.go create mode 100644 mdl/executor/import_mapping_content_type_1232_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-import-mapping-call-content-type-hardcoded-json.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-import-mapping-call-content-type-hardcoded-json.json new file mode 100644 index 000000000..b201b77a7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-17-import-mapping-call-content-type-hardcoded-json.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-17", "symptom": "Re-executing a microflow with `$V = import from mapping M.Import_mapping($latestHttpResponse);` whose mapping is built on a web service operation: mx check \"The selected import mapping cannot handle content type JSON.\" Opening the activity in Studio Pro fixes it: \"This action now imports XML\"", "cause": "`importXmlActionToGen` (and the REST mapping result handling in `restResultHandlingToGen`) wrote `ImportMappingCall.ContentType` as the constant \"Json\"; the reader never read it, so even a pass-through rewrite of a stored XML import turned it into JSON", "file": "`mdl/backend/modelsdk/microflow_write.go`, `mdl/backend/modelsdk/microflow_read_actions.go` (`readMappingCall`), `mdl/executor/cmd_microflows_builder_calls.go` (`importMappingContentType`)", "insight": "The content type is a property of the MAPPING's source (XML schema or web service → Xml, JSON structure → Json), which the builder already fetches for the result entity — derive it there, and carry the stored value on read so a rewrite never re-derives what it cannot see. `call web service` already wrote Xml (microflow_webservice_write.go); grep `\"ContentType\", \"Json\"` for any other constant. Not run against mxbuild: MDL cannot author an XML schema or WSDL import and no testdata fixture has a mapping built on one (grep for `ImportedWebService` in mprcontents finds none) — the reporter's mx check on 11.12.4 is the evidence that Json is refused. Message-definition mappings stay Json, unmeasured", "refs": ["#1290", "mendixlabs/mxcli#1232"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6313b52..7bd87310a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **`describe module` prints an entity's grants after the associations they name** (mendixlabs/mxcli#1409) — an entity's grant naming one of its associations was printed with the entity, ahead of the association, so replaying the output stopped with "entity ModA.EntA has no member(s) EntA_EntB", leaving the entity without its association or access. `check` passed the same script; it now reports a grant naming an association created further down the script, with the reorder. +- **`import from mapping` over an XML schema or web service mapping imports XML** (mendixlabs/mxcli#1232) — the activity was always written with content type JSON, so re-executing a microflow whose import uses a mapping from a web service operation failed `mx check` with "The selected import mapping cannot handle content type JSON." until the activity was opened in Studio Pro. The content type now follows the mapping's source, and a stored one is kept on rewrite; a REST call's mapping result handling gets the same. - **Workflow user tasks keep "Auto-assign when targeting results in one user"** (mendixlabs/mxcli#1547) — every single- and multi-user task was written with `AutoAssignSingleTargetUser = false`, MDL had no spelling for it and `describe workflow` did not print it, so `create or modify workflow` turned off a box ticked in Studio Pro. When the targeting then yielded one user, the task was not assigned to them and completing it from a microflow failed at runtime with "You can't complete this user task, it is not assigned to you"; `check` and `mx check` were silent. The new clause `auto assign single user` (on `user task` and `multi user task`) writes it, `describe` prints it when set, and a `create or modify` or `replace activity` that does not restate a stored one is refused, like `await all users`. Omitted, it is off, which is what Studio Pro 11.14 stores for a new task. The MCP backend sent `false` too and now sends the task's value. ## [0.27.0] - 2026-10-15 diff --git a/mdl-examples/bug-tests/1232-import-mapping-xml-content-type.mdl b/mdl-examples/bug-tests/1232-import-mapping-xml-content-type.mdl new file mode 100644 index 000000000..cd5244066 --- /dev/null +++ b/mdl-examples/bug-tests/1232-import-mapping-xml-content-type.mdl @@ -0,0 +1,52 @@ +mdl 1; +-- ============================================================================ +-- Bug #1232 — regenerating an import with mapping gives a model error. +-- +-- `import from mapping` was always written with ImportMappingCall.ContentType +-- "Json". Re-executing a microflow whose import uses a mapping sourced from a +-- web service operation (or an XML schema) then failed mx check with +-- +-- The selected import mapping cannot handle content type JSON. +-- +-- until the activity was opened in Studio Pro ("This action now imports XML"). +-- The content type now follows the mapping's source, and a stored one is kept +-- when the microflow is rewritten. +-- +-- The reported script, against an app whose ExamplesModule.Import_mapping is +-- built on a web service operation (MDL cannot create one — a WSDL import is +-- Studio Pro's): +-- +-- create or modify microflow ExamplesModule.ACT_test_import () +-- folder 'Input' +-- begin +-- @start(100, 200) +-- @position(291, 200) +-- $Variable_2 = import from mapping ExamplesModule.Import_mapping($latestHttpResponse); +-- @position(700, 200) +-- return; +-- end; +-- +-- What this file runs is the JSON control, which must keep writing "Json". +-- ============================================================================ + +create module Issue1232; + +create json structure Issue1232.JSON_Order sample '{"orderNumber": 1}'; + +@position(100, 100) +create non-persistent entity Issue1232.Order ( + OrderNumber: integer +); + +create import mapping Issue1232.IMM_Order + with json structure Issue1232.JSON_Order { + create Issue1232.Order { + OrderNumber = orderNumber + } +}; + +create microflow Issue1232.ACT_Import ($Payload: string) +begin + $Order = import from mapping Issue1232.IMM_Order($Payload); + return; +end; diff --git a/mdl/backend/modelsdk/microflow_import_content_type_1232_test.go b/mdl/backend/modelsdk/microflow_import_content_type_1232_test.go new file mode 100644 index 000000000..eea62c08e --- /dev/null +++ b/mdl/backend/modelsdk/microflow_import_content_type_1232_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/modelsdk/element" + "github.com/mendixlabs/mxcli/sdk/microflows" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// mendixlabs/mxcli#1232 (ako/mxcli#1290): re-executing the describe output of +// an `import from mapping` whose mapping is sourced from a web service +// operation stored ImportMappingCall.ContentType "Json", and mx check refused +// it — "The selected import mapping cannot handle content type JSON." The +// content type is the mapping's, not a constant: it is carried on read and +// written as given. + +func importContentTypeOf(t *testing.T, g element.Element) string { + t.Helper() + raw, err := (&codec.Encoder{}).Encode(g) + if err != nil { + t.Fatalf("encode: %v", err) + } + var doc bson.Raw = raw + rh, ok := doc.Lookup("ResultHandling").DocumentOK() + if !ok { + t.Fatal("no ResultHandling") + } + imc, ok := rh.Lookup("ImportMappingCall").DocumentOK() + if !ok { + t.Fatal("no ImportMappingCall") + } + return rawStr(imc, "ContentType") +} + +func TestWriteImportMappingCallContentType(t *testing.T) { + for _, tc := range []struct{ given, want string }{ + {"Xml", "Xml"}, + {"Json", "Json"}, + {"", "Json"}, // nothing known: what every caller wrote before + } { + h := µflows.ResultHandlingMapping{MappingID: "M.IMM", ContentType: tc.given} + g := importXmlActionToGen(µflows.ImportXmlAction{ResultHandling: h}) + if got := importContentTypeOf(t, g); got != tc.want { + t.Errorf("import from mapping, ContentType %q: wrote %q, want %q", tc.given, got, tc.want) + } + r := restResultHandlingToGen(h, "out") + raw, err := (&codec.Encoder{}).Encode(r) + if err != nil { + t.Fatal(err) + } + var rh bson.Raw = raw + imc, _ := rh.Lookup("ImportMappingCall").DocumentOK() + if got := rawStr(imc, "ContentType"); got != tc.want { + t.Errorf("rest call mapping, ContentType %q: wrote %q, want %q", tc.given, got, tc.want) + } + } +} + +func TestReadImportMappingCallContentType(t *testing.T) { + raw := mustMarshalFlow(bson.D{ + {Key: "$ID", Value: "a-1"}, + {Key: "$Type", Value: "Microflows$ImportXmlAction"}, + {Key: "ErrorHandlingType", Value: "Rollback"}, + {Key: "XmlDocumentVariableName", Value: "latestHttpResponse"}, + {Key: "ResultHandling", Value: bson.D{ + {Key: "$ID", Value: "rh-1"}, + {Key: "$Type", Value: "Microflows$ResultHandling"}, + {Key: "ResultVariableName", Value: "Variable_2"}, + {Key: "ImportMappingCall", Value: bson.D{ + {Key: "$ID", Value: "imc-1"}, + {Key: "$Type", Value: "Microflows$ImportMappingCall"}, + {Key: "ContentType", Value: "Xml"}, + {Key: "ReturnValueMapping", Value: "ExamplesModule.Import_mapping"}, + {Key: "ForceSingleOccurrence", Value: false}, + {Key: "Range", Value: bson.D{{Key: "$Type", Value: "Microflows$ConstantRange"}, {Key: "SingleObject", Value: false}}}, + }}, + {Key: "VariableType", Value: bson.D{{Key: "$Type", Value: "DataTypes$ObjectType"}, {Key: "Entity", Value: "M.Root"}}}, + }}, + }) + h := readImportRange(t, raw) + if h.ContentType != "Xml" { + t.Fatalf("read ContentType = %q, want Xml", h.ContentType) + } + g := importXmlActionToGen(µflows.ImportXmlAction{ResultHandling: h}) + if got := importContentTypeOf(t, g); got != "Xml" { + t.Errorf("rewrite of a stored Xml import wrote ContentType %q", got) + } +} diff --git a/mdl/backend/modelsdk/microflow_read_actions.go b/mdl/backend/modelsdk/microflow_read_actions.go index 614fba9c4..d1098dc66 100644 --- a/mdl/backend/modelsdk/microflow_read_actions.go +++ b/mdl/backend/modelsdk/microflow_read_actions.go @@ -940,6 +940,7 @@ func readMappingCall(doc, imc bson.Raw) (h *microflows.ResultHandlingMapping, fo mapping = rawStr(imc, "ReturnValueMapping") } h.MappingID = model.ID(mapping) + h.ContentType = rawStr(imc, "ContentType") force, _ = imc.Lookup("ForceSingleOccurrence").BooleanOK() h.ForceSingleOccurrence = &force // The Range is polymorphic: a ConstantRange carries SingleObject (All/First) diff --git a/mdl/backend/modelsdk/microflow_write.go b/mdl/backend/modelsdk/microflow_write.go index a26fc2872..cda3698a7 100644 --- a/mdl/backend/modelsdk/microflow_write.go +++ b/mdl/backend/modelsdk/microflow_write.go @@ -1031,7 +1031,7 @@ func importXmlActionToGen(a *microflows.ImportXmlAction) element.Element { imc := newElem("Microflows$ImportMappingCall", "") addStr(imc, "Commit", "YesWithoutEvents") - addStr(imc, "ContentType", "Json") + addStr(imc, "ContentType", orDefault(rh.ContentType, "Json")) addBool(imc, "ForceSingleOccurrence", forceSingle) addStr(imc, "ObjectHandlingBackup", "Create") addStr(imc, "ParameterVariableName", "") @@ -1847,7 +1847,7 @@ func restResultHandlingToGen(rh microflows.ResultHandling, outputVar string) ele } imc := newElem("Microflows$ImportMappingCall", "") addStr(imc, "Commit", "YesWithoutEvents") - addStr(imc, "ContentType", "Json") + addStr(imc, "ContentType", orDefault(h.ContentType, "Json")) addBool(imc, "ForceSingleOccurrence", forceSingle) addStr(imc, "ObjectHandlingBackup", "Create") addStr(imc, "ParameterVariableName", "") diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 2eb033263..1953ffc26 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1699,10 +1699,14 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { // three reference documents store Range.SingleObject=false. singleObject := !s.Result.IsList fso := false - if singleObject && fb.backend != nil { + contentType := "" + if fb.backend != nil { if im, err := fb.backend.GetImportMappingByQualifiedName( - s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil { - fso = fb.mappingRootIsList(im) + s.Result.MappingName.Module, s.Result.MappingName.Name); err == nil && im != nil { + if singleObject { + fso = fb.mappingRootIsList(im) + } + contentType = importMappingContentType(im) } } rangeAll := false @@ -1714,6 +1718,7 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { SingleObject: singleObject, ForceSingleOccurrence: &fso, RangeSingleObject: &rangeAll, + ContentType: contentType, } case ast.RestResultNone: resultHandling = µflows.ResultHandlingNone{ @@ -2023,6 +2028,17 @@ func jsonMappingRootIsList(lookup jsonStructureLookup, im *model.ImportMapping) return types.JsonMappingRootIsList(lookup, im) } +// importMappingContentType is the content type an import mapping call takes +// for the mapping: XML when it is sourced from an XML schema or a web service +// operation, JSON otherwise — what Studio Pro sets when the activity is opened +// ("This action now imports XML"; mendixlabs/mxcli#1232). +func importMappingContentType(im *model.ImportMapping) string { + if im.XmlSchema != "" || im.WebServiceSource.IsSet() { + return "Xml" + } + return "Json" +} + // addImportFromMappingAction adds an ImportXmlAction to the microflow. func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) model.ID { activityX := fb.posX @@ -2114,6 +2130,7 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) if !rangeAuthored && fb.mappingRootIsList(im) { resultHandling.SingleObject = false } + resultHandling.ContentType = importMappingContentType(im) if len(im.Elements) > 0 && im.Elements[0] != nil && im.Elements[0].Entity != "" { resultEntityQN = im.Elements[0].Entity resultHandling.ResultEntityID = model.ID(resultEntityQN) diff --git a/mdl/executor/import_mapping_content_type_1232_test.go b/mdl/executor/import_mapping_content_type_1232_test.go new file mode 100644 index 000000000..39774bf8a --- /dev/null +++ b/mdl/executor/import_mapping_content_type_1232_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1232 (ako/mxcli#1290): `import from mapping` over a mapping +// sourced from a web service operation was written with ContentType Json, and +// mx check refused the replayed microflow: "The selected import mapping cannot +// handle content type JSON." Studio Pro fixes it on opening the activity — +// "This action now imports XML" — so the content type follows the mapping's +// source: XML for an XML schema or a web service, JSON otherwise. +func TestImportFromMapping_ContentTypeFollowsTheMappingSource(t *testing.T) { + ws := model.WebServiceMappingSource{ImportedWebService: "ExamplesModule.WS", OperationName: "Op"} + for _, tc := range []struct { + name string + im model.ImportMapping + want string + }{ + {"web service operation (the reported shape)", model.ImportMapping{WebServiceSource: ws}, "Xml"}, + {"XML schema", model.ImportMapping{XmlSchema: "ExamplesModule.Schema"}, "Xml"}, + {"JSON structure (control)", model.ImportMapping{JsonStructure: "ExamplesModule.Json"}, "Json"}, + } { + t.Run(tc.name, func(t *testing.T) { + im := tc.im + im.Elements = []*model.ImportMappingElement{{Kind: "Object", Entity: "ExamplesModule.Root", MaxOccurs: 1}} + fb := &flowBuilder{ + varTypes: map[string]string{}, + backend: &mock.MockBackend{ + GetImportMappingByQualifiedNameFunc: func(string, string) (*model.ImportMapping, error) { return &im, nil }, + }, + } + fb.addImportFromMappingAction(&ast.ImportFromMappingStmt{ + OutputVariable: "Variable_2", + SourceVariable: "latestHttpResponse", + Mapping: ast.QualifiedName{Module: "ExamplesModule", Name: "Import_mapping"}, + }) + act := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity).Action.(*microflows.ImportXmlAction) + if got := act.ResultHandling.ContentType; got != tc.want { + t.Errorf("ContentType = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index 61d0b43bf..7aa0a2b50 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -1161,6 +1161,13 @@ type ResultHandlingMapping struct { // than merely undescribed). LimitExpression string `json:"limitExpression,omitempty"` OffsetExpression string `json:"offsetExpression,omitempty"` + + // ContentType is the ImportMappingCall's "Json" or "Xml", which follows the + // mapping's source: XML for an XML schema or a web service operation. A + // constant "Json" made mx check refuse a web-service mapping with "The + // selected import mapping cannot handle content type JSON" + // (mendixlabs/mxcli#1232). Empty writes "Json". + ContentType string `json:"contentType,omitempty"` } func (ResultHandlingMapping) isResultHandling() {} From 758edf8c3ef8bc0f700be5261b3eef21577e63e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:49:09 +0000 Subject: [PATCH 18/22] =?UTF-8?q?feat:=20sidebartoggle=20=E2=80=94=20autho?= =?UTF-8?q?r=20Atlas's=20responsive=20sidebar=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying Atlas_Core.Atlas_Default with `describe layout` -> `create layout` dropped its Forms$SidebarToggleButton, which describe printed as a NOT re-executable comment; Atlas's slide-in navigation on phones and tablets depends on it (mendixlabs/mxcli#1220, ako/mxcli#1285). `sidebartoggle name (Caption, Tooltip, ButtonStyle, Icon, RenderMode)` shares an action button's face and has no action. The written widget has the key set of Atlas's own; modelsdk/gen bound its caption under the SDK name `Caption`, so the storage key is overridden to `CaptionTemplate` and struck off the key-audit ledger. The toggle only works on a region that can be toggled: a real mx check of the copy reported CE0628 because a scroll container region's ToggleMode was never described or written. Regions now round-trip `ToggleMode`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wx8dgxQm9qE8ZsMxwRweXj --- ...6-10-17-sidebar-toggle-not-authorable.json | 1 + .claude/skills/mendix/write-layouts/SKILL.md | 35 ++-- CHANGELOG.md | 4 + cmd/mxcli/lsp_completions_gen.go | 1 + cmd/mxcli/syntax/features_page.go | 4 + .../syntax/widget_keywords_drift_test.go | 1 + docs-site/src/reference/page/alter-layout.md | 7 +- docs-site/src/reference/page/create-layout.md | 16 +- .../bug-tests/1220-sidebar-toggle.mdl | 42 +++++ mdl/backend/modelsdk/widget_write.go | 27 +++ mdl/executor/cmd_pages_builder_v3.go | 30 ++++ mdl/executor/cmd_pages_builder_v3_widgets.go | 99 ++++++++--- mdl/executor/cmd_pages_describe.go | 3 + mdl/executor/cmd_pages_describe_output.go | 17 +- mdl/executor/cmd_pages_describe_parse.go | 15 ++ .../cmd_pages_describe_sidebartoggle_test.go | 112 ++++++++++++ .../design_property_keyword_keys_test.go | 5 +- .../sidebartoggle_copy_layout_pedapp_test.go | 161 ++++++++++++++++++ mdl/executor/theme_reader.go | 8 +- mdl/executor/validate_page_context.go | 2 +- mdl/executor/validate_widgets.go | 5 +- mdl/executor/widget_keywords.go | 2 +- mdl/grammar/MDLLexer.g4 | 1 + mdl/grammar/domains/MDLPage.g4 | 1 + mdl/grammar/domains/MDLSettings.g4 | 2 +- modelsdk/gen/keyaudit_test.go | 1 - modelsdk/gen/pages/types.go | 7 +- sdk/pages/pages_widgets_advanced.go | 17 ++ sdk/pages/pages_widgets_container.go | 9 +- 29 files changed, 578 insertions(+), 57 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json create mode 100644 mdl-examples/bug-tests/1220-sidebar-toggle.mdl create mode 100644 mdl/executor/cmd_pages_describe_sidebartoggle_test.go create mode 100644 mdl/executor/sidebartoggle_copy_layout_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json new file mode 100644 index 000000000..e2eed982b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`describe layout Atlas_Core.Atlas_Default` printed `-- Forms$SidebarToggleButton (sidebarToggle3) -- NOT re-executable: mxcli cannot author this widget`, so a copied layout lost Atlas's responsive sidebar toggle", "cause": "No MDL keyword or writer for Forms$SidebarToggleButton; modelsdk/gen bound its caption under the SDK name `Caption` (the key-audit ledger listed it) where Studio Pro stores `CaptionTemplate`; and the scroll container region's `ToggleMode` was neither described nor written, so every region came out `None`", "file": "`mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildSidebarToggleV3`, `buildButtonFaceV3`), `mdl/backend/modelsdk/widget_write.go`, `mdl/executor/cmd_pages_describe_output.go`, `modelsdk/gen/pages/types.go` (initSidebarToggleButton)", "insight": "Writing the widget was not enough: the first real `mxcli docker check` of the copy reported CE0628 \"For the sidebar toggle button to work, the outermost widget on the layout must be a scroll container with a region that can be toggled\" because the region's ToggleMode (ShrinkContentInitiallyClosed on Atlas's sidebar) had been dropped all along — invisible until a toggle existed. A widget's correctness can live in a SIBLING's property; run mxbuild on a describe→exec copy, diffing its errors against an untouched copy of the fixture (PedApp has no theme, so ~1000 CE6083s are baseline noise). Compare the written key set to the Studio Pro document (test `TestCopyAtlasDefaultKeepsTheSidebarToggle`) — reverting the gen key override fails it with `Caption` vs `CaptionTemplate`. A widget keyword parsed as a bare IDENTIFIER is TypeIsGeneric and refused as MDL-WIDGET25, so a builder-owned widget needs a lexer token. Separate, pre-existing on main: the copy also reports CE0535 \"weights of the columns in a row must add up to 12, but currently they add up to -3\" for a layout grid row with AutoFill/AutoFit columns", "refs": ["#1285", "mendixlabs/mxcli#1220"]} diff --git a/.claude/skills/mendix/write-layouts/SKILL.md b/.claude/skills/mendix/write-layouts/SKILL.md index 5c9ec1228..ed72689fc 100644 --- a/.claude/skills/mendix/write-layouts/SKILL.md +++ b/.claude/skills/mendix/write-layouts/SKILL.md @@ -10,15 +10,15 @@ description: "CREATE LAYOUT syntax — the frame a page is built on: scroll-cont | lines | section | |---|---| -| 30-135 | Do not edit Atlas_Core | -| 136-195 | Syntax | -| 196-235 | The four things only a layout has | -| 236-247 | Layout type, and why there is no `native:` flag | -| 248-269 | The layout class is load-bearing | -| 270-316 | Gotchas | -| 317-329 | Validate | -| 330-342 | What `mxcli new` scaffolds | -| 343-345 | Layouts (SHOW/DESCRIBE/CREATE [OR REPLACE] LAYOUT) | +| 30-134 | Do not edit Atlas_Core | +| 135-194 | Syntax | +| 195-234 | The four things only a layout has | +| 235-246 | Layout type, and why there is no `native:` flag | +| 247-268 | The layout class is load-bearing | +| 269-317 | Gotchas | +| 318-330 | Validate | +| 331-343 | What `mxcli new` scaffolds | +| 344-346 | Layouts (SHOW/DESCRIBE/CREATE [OR REPLACE] LAYOUT) | # CREATE LAYOUT — the frame a page is built on @@ -52,8 +52,7 @@ copy operation. There is no `COPY DOCUMENT` verb and none is needed. **A copy is only as good as what MDL can spell.** A widget describe cannot render comes out as a comment ending `-- NOT re-executable`, and re-running the script -drops it. Measured on `Atlas_Core.Atlas_SideBar`: its two -`Forms$SidebarToggleButton` widgets do not survive the round trip, and an +drops it. Measured: an `image` widget copied this way loses its image reference (CE0463 until `mxcli fix widgets`, then "No image selected"). **Read the describe output before running it** — the comments name exactly what will be lost. To change a @@ -303,10 +302,12 @@ section. one, where a top-level block fills the outer layout (see above). - **A layout that declares no placeholder is refused at write time** as well as at check time — no page could use it. -- **The sidebar toggle, the menu bar's logo and Atlas's `Forms$Header` are not - authorable.** A topbar layout that needs a collapsible sidebar therefore has to - keep Atlas's, or do without the toggle — which is why `mxcli new` scaffolds a - topbar-navigation layout with no sidebar rather than an always-open one. +- **The sidebar toggle is `sidebartoggle`** (mendixlabs/mxcli#1220) — an action + button's `Caption`, `Tooltip`, `ButtonStyle`, `Icon` and `RenderMode: Link`, + with no `Action`: it opens and closes the layout's sidebar, which is how + Atlas's responsive layouts navigate on phones and tablets. A copy of + `Atlas_Core.Atlas_Default` keeps it. +- **The menu bar's logo and Atlas's `Forms$Header` are not authorable.** - **Authoring is modelsdk-only.** The legacy writer cannot produce the `Content` wrapper the widget tree hangs off; it refuses rather than writing a layout with no tree. @@ -334,8 +335,8 @@ owns — and its pages moved onto it, so the documented practice is the default rather than something to discover. `mxcli new --layout none` keeps Atlas's. It is **not** a copy of Atlas_TopBar, and could not be: that layout carries a -`Forms$MenuBar` (now authorable), a `Forms$SidebarToggleButton` and a pluggable -image, so a describe → exec copy renders with no navigation and no logo. The +`Forms$MenuBar` and a `Forms$SidebarToggleButton` (both authorable now) and a +pluggable image, so a describe → exec copy renders with no logo. The scaffold reproduces the *result* instead — same layout class, same region classes, navigation in the topbar, `Main` for page content — and omits the toggle button and the stock logo. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd87310a..ba3ee421f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`sidebartoggle` — Atlas's responsive sidebar toggle (`Forms$SidebarToggleButton`) in MDL** (mendixlabs/mxcli#1220) — copying `Atlas_Core.Atlas_Default` with `describe layout` → `create layout` dropped the button that opens the sidebar on phones and tablets: `describe` printed it as a NOT re-executable comment, and `exec` gave no warning. `sidebartoggle sidebarToggle3 (Tooltip: 'Toggle Menu', ButtonStyle: Primary, Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', Class: 'toggle-btn')` takes an action button's caption, tooltip, style and icon, and `RenderMode: Link`; it has no action. The written widget has the keys of Atlas's own, including its caption under `CaptionTemplate` rather than the `Caption` key the vendored metamodel bound. + ### Fixed - **`describe module` prints an entity's grants after the associations they name** (mendixlabs/mxcli#1409) — an entity's grant naming one of its associations was printed with the entity, ahead of the association, so replaying the output stopped with "entity ModA.EntA has no member(s) EntA_EntB", leaving the entity without its association or access. `check` passed the same script; it now reports a grant naming an association created further down the script, with the reorder. diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index 6825d046e..d9cad433d 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -190,6 +190,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "REGION", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "MENUBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SIMPLEMENUBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, + {Label: "SIDEBARTOGGLE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "NAVIGATIONTREE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SNIPPETCALL", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "LAYOUTGRID", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index eabdc963d..28ca188ab 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -478,6 +478,7 @@ LIST IMPACT OF htmlelement; "layout", "layouts", "create layout", "scrollcontainer", "region", "placeholder", "navigationtree", "topbar", "sidebar", "frame", "menubar", "simplemenubar", "bottom bar", "phone layout", "menu document", + "sidebartoggle", "sidebar toggle", "toggle button", }, Syntax: "CREATE [OR MODIFY] LAYOUT Module.Name (\n" + " layouttype: 'Responsive' | 'Phone' | 'Tablet' | 'ModalPopup' -- web\n" + @@ -494,6 +495,9 @@ LIST IMPACT OF htmlelement; " MENUBAR name (Profile: 'Responsive') -- horizontal, for a topbar\n" + " SIMPLEMENUBAR name (Menu: Module.Menu -- a phone layout's bottom bar\n" + " [, Orientation: Horizontal | Vertical])\n" + + " SIDEBARTOGGLE name ([Caption: '…', Tooltip: '…', ButtonStyle: Primary,\n" + + " Icon: 'Mod.Collection.icon', RenderMode: Button | Link])\n" + + " -- the button that opens and closes the sidebar on phones and tablets\n" + " -- Every menu widget renders EITHER a navigation profile (Profile: '…')\n" + " -- OR a menu document (Menu: Module.Menu, see CREATE MENU) — not both.\n" + " PLACEHOLDER Main\n" + diff --git a/cmd/mxcli/syntax/widget_keywords_drift_test.go b/cmd/mxcli/syntax/widget_keywords_drift_test.go index e7c425fcd..3b80f256f 100644 --- a/cmd/mxcli/syntax/widget_keywords_drift_test.go +++ b/cmd/mxcli/syntax/widget_keywords_drift_test.go @@ -96,6 +96,7 @@ var documentedElsewhere = map[string]string{ "navigationtree": "layout", "menubar": "layout", "simplemenubar": "layout", + "sidebartoggle": "layout", "placeholder": "layout", // Object-list container keywords for pluggable widgets: each is the singular // form of one widget's own list property (Accordion groups → GROUP), routed diff --git a/docs-site/src/reference/page/alter-layout.md b/docs-site/src/reference/page/alter-layout.md index e9626efc5..b352719b3 100644 --- a/docs-site/src/reference/page/alter-layout.md +++ b/docs-site/src/reference/page/alter-layout.md @@ -52,10 +52,9 @@ tree *is* a page's plus four element types. This is the tool for changing a layout you did not write. `CREATE OR REPLACE LAYOUT` rebuilds the document from your MDL, which means it can only reproduce what MDL can express; `ALTER LAYOUT` edits what is stored and leaves everything -it was not asked about untouched — including widgets MDL has no syntax for. -Measured on `Atlas_Core.Atlas_SideBar`, a describe → rename → run copy loses -both of its `Forms$SidebarToggleButton` widgets. An `ALTER LAYOUT` against it -does not. +it was not asked about untouched — including widgets MDL has no syntax for, +which a describe → rename → run copy drops (`describe` names each one in a +`-- NOT re-executable` comment). An `ALTER LAYOUT` against it does not. Like `CREATE LAYOUT`, it refuses a Marketplace target and names the copy-then-repoint route in the error. diff --git a/docs-site/src/reference/page/create-layout.md b/docs-site/src/reference/page/create-layout.md index 1a356fc0f..7923a90ac 100644 --- a/docs-site/src/reference/page/create-layout.md +++ b/docs-site/src/reference/page/create-layout.md @@ -59,8 +59,20 @@ mxcli exec mine.mdl -p app.mpr Read the describe output before running it. A widget MDL cannot express is emitted as a comment ending `-- NOT re-executable`, naming exactly what a -re-run would drop (`Atlas_Core.Atlas_SideBar`, for instance, loses both of its -`Forms$SidebarToggleButton` widgets). +re-run would drop. + +Atlas's responsive sidebar toggle is the `sidebartoggle` widget, so a copied +layout keeps its slide-in navigation on phones and tablets: + +```sql +sidebartoggle sidebarToggle3 ( + Tooltip: 'Toggle Menu', ButtonStyle: Primary, + Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', Class: 'toggle-btn' +) +``` + +It takes an action button's `Caption`, `Tooltip`, `ButtonStyle` and `Icon`, +and `RenderMode: Link` to render as a link; it has no `Action`. ## Parameters diff --git a/mdl-examples/bug-tests/1220-sidebar-toggle.mdl b/mdl-examples/bug-tests/1220-sidebar-toggle.mdl new file mode 100644 index 000000000..3ada96391 --- /dev/null +++ b/mdl-examples/bug-tests/1220-sidebar-toggle.mdl @@ -0,0 +1,42 @@ +mdl 1; +-- ============================================================================ +-- Bug #1220 — copying Atlas_Core.Atlas_Default with `describe layout` → +-- `create layout` lost the responsive sidebar toggle. describe printed +-- +-- -- Forms$SidebarToggleButton (sidebarToggle3) -- NOT re-executable: +-- -- mxcli cannot author this widget, so re-running this script would drop it +-- +-- and Atlas's slide-in navigation on phones and tablets depends on it. +-- +-- `sidebartoggle` writes the widget with the keys Atlas's own carries. The +-- region it opens needs a ToggleMode: without one mxbuild reports CE0628 "For +-- the sidebar toggle button to work, the outermost widget on the layout must +-- be a scroll container with a region that can be toggled" (measured on +-- 11.13.0), so the region's ToggleMode round-trips too. +-- +-- mxcli exec this-file -p app.mpr && mxcli docker check -p app.mpr +-- ============================================================================ + +create or modify layout MyFirstModule.App_Sidebar1220 ( + layouttype: 'Responsive', + class: 'layout-atlas layout-atlas-responsive-default' +) { + scrollcontainer layoutContainer { + region top (Class: 'region-topbar') { + container topbarContent (Class: 'topbar-content') { + sidebartoggle sidebarToggle1 ( + Tooltip: 'Toggle Menu', + ButtonStyle: Primary, + Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', + Class: 'toggle-btn' + ) + } + } + region left (Size: 232, SizeMode: 'Pixels', ToggleMode: 'ShrinkContentInitiallyClosed', Class: 'region-sidebar') { + navigationtree navigationTree1 (Profile: 'Responsive') + } + region center (Class: 'region-content') { + placeholder Main + } + } +}; diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 6102da273..f3738a1f5 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -135,6 +135,12 @@ func init() { NullFields: []string{"Icon", "ConditionalVisibilitySettings", "NativeAccessibilitySettings"}, }) codec.RegisterListMarker("Forms$ActionButton", 2) + // SidebarToggleButton: null Icon/visibility slots; marker 2 as a widget. + // Measured on Atlas_Core.Atlas_Default (11.13.0, mendixlabs/mxcli#1220). + codec.RegisterTypeDefaults("Forms$SidebarToggleButton", codec.TypeDefaults{ + NullFields: []string{"Icon", "ConditionalVisibilitySettings"}, + }) + codec.RegisterListMarker("Forms$SidebarToggleButton", 2) // Title: null visibility/accessibility slots; marker 2 as a widget. codec.RegisterTypeDefaults("Forms$Title", codec.TypeDefaults{ NullFields: []string{"ConditionalVisibilitySettings", "NativeAccessibilitySettings"}, @@ -825,6 +831,26 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetOrientation(orDefaultStr(string(x.Orientation), string(pages.MenuOrientationHorizontal))) return g, nil + case *pages.SidebarToggleButton: + // An action button's face without the action (mendixlabs/mxcli#1220). + // Studio Pro always stores a CaptionTemplate, empty on Atlas's own. + g := genPg.NewSidebarToggleButton() + applyWidgetBase(g, &x.BaseWidget) + g.SetRenderType(orDefaultStr(string(x.RenderMode), "Button")) + g.SetButtonStyle(orDefaultStr(string(x.ButtonStyle), "Default")) + if x.CaptionTemplate != nil { + g.SetCaption(clientTemplateToGen(x.CaptionTemplate)) + } else { + g.SetCaption(textAsClientTemplate(nil)) + } + g.SetTooltip(captionToGen(x.Tooltip)) + if x.Icon != nil { + if icon := iconToGen(x.Icon); icon != nil { + g.SetIcon(icon) + } + } + return g, nil + case *pages.GroupBox: g := genPg.NewGroupBox() applyWidgetBase(g, &x.BaseWidget) @@ -2298,6 +2324,7 @@ func scrollRegionToGen(r *pages.ScrollContainerRegion) (element.Element, error) } g.SetSize(int32(size)) g.SetSizeMode(orDefaultStr(r.SizeMode, "Auto")) + g.SetToggleMode(orDefaultStr(r.ToggleMode, "None")) for _, w := range r.Widgets { wg, err := widgetToGen(w) if err != nil { diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 07a57466f..9932a9c37 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -450,6 +450,8 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) { widget, err = pb.buildMenuBarV3(w) case "simplemenubar": widget, err = pb.buildSimpleMenuBarV3(w) + case "sidebartoggle": + widget, err = pb.buildSidebarToggleV3(w) case "placeholder": widget, err = pb.buildPlaceholderV3(w) case "radiobuttons": @@ -3307,12 +3309,19 @@ func (pb *pageBuilder) buildScrollContainerV3(w *ast.WidgetV3) (pages.Widget, er } seen[slot] = true + toggleMode, ok := canonicalRegionToggleMode(child.GetStringProp("ToggleMode")) + if !ok { + return nil, mdlerrors.NewValidation(fmt.Sprintf( + "scrollcontainer %q: region %q: unknown ToggleMode %q (want %s)", + w.Name, slot, child.GetStringProp("ToggleMode"), strings.Join(regionToggleModes, ", "))) + } region := &pages.ScrollContainerRegion{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID()), TypeName: "Forms$ScrollContainerRegion"}, Slot: slot, Class: child.GetStringProp("Class"), SizeMode: child.GetStringProp("SizeMode"), Size: child.GetIntProp("Size"), + ToggleMode: toggleMode, } for _, gc := range child.Children { cw, err := pb.buildWidgetV3(gc) @@ -3326,6 +3335,27 @@ func (pb *pageBuilder) buildScrollContainerV3(w *ast.WidgetV3) (pages.Widget, er return sc, nil } +// regionToggleModes are the values of Forms$ScrollContainerRegion.ToggleMode: +// how a sidebar toggle button opens and closes the region. +var regionToggleModes = []string{ + "None", "PushContentAside", "SlideOverContent", + "ShrinkContentInitiallyOpen", "ShrinkContentInitiallyClosed", +} + +// canonicalRegionToggleMode spells a region's ToggleMode as Mendix stores it, +// case-insensitively; "" (not said) stays "", which writes None. +func canonicalRegionToggleMode(mode string) (string, bool) { + if mode == "" { + return "", true + } + for _, m := range regionToggleModes { + if strings.EqualFold(m, mode) { + return m, true + } + } + return "", false +} + // buildNavigationTreeV3 builds the sidebar menu. The profile is stored inside a // Forms$NavigationSource, not on the tree — see widget_write.go. func (pb *pageBuilder) buildNavigationTreeV3(w *ast.WidgetV3) (pages.Widget, error) { diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 350ee5985..ec74d5d09 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -1190,9 +1190,48 @@ func (pb *pageBuilder) buildButtonV3(w *ast.WidgetV3) (*pages.ActionButton, erro btn.RenderMode = pages.ButtonRenderModeLink } + face, err := pb.buildButtonFaceV3(w) + if err != nil { + return nil, err + } + btn.CaptionTemplate, btn.Tooltip = face.caption, face.tooltip + if face.style != "" { + btn.ButtonStyle = face.style + } + btn.Icon = face.icon + + // Handle Action + if action := w.GetAction(); action != nil { + act, err := pb.buildClientActionV3(action) + if err != nil { + return nil, mdlerrors.NewBackend("build action", err) + } + btn.Action = act + } + + if err := pb.registerWidgetName(w.Name, btn.ID); err != nil { + return nil, err + } + + return btn, nil +} + +// buttonFace is what a button shows: its caption, tooltip, style and icon. +// An action button and a sidebar toggle button share it. +type buttonFace struct { + caption *pages.ClientTemplate + tooltip *model.Text + style pages.ButtonStyle // "" when the author did not say + icon *pages.Icon +} + +// buildButtonFaceV3 reads a button's Caption (with CaptionParams), Tooltip, +// ButtonStyle and Icon. +func (pb *pageBuilder) buildButtonFaceV3(w *ast.WidgetV3) (buttonFace, error) { + var face buttonFace // Handle Caption if caption := w.GetCaption(); caption != "" { - btn.CaptionTemplate = &pages.ClientTemplate{ + face.caption = &pages.ClientTemplate{ BaseElement: model.BaseElement{ ID: model.ID(types.GenerateID()), TypeName: "Forms$ClientTemplate", @@ -1216,14 +1255,14 @@ func (pb *pageBuilder) buildButtonV3(w *ast.WidgetV3) (*pages.ActionButton, erro if params == nil { params = w.GetContentParams() } - btn.CaptionTemplate.Parameters = pb.buildClientTemplateParams(params) + face.caption.Parameters = pb.buildClientTemplateParams(params) } // Tooltip is a plain Texts$Text in the authoring language. The grammar and // MDL-WIDGET07 both accepted it while nothing here read it, so it was // dropped without a word (mendixlabs/mxcli#1307). if tooltip := w.GetStringProp("Tooltip"); tooltip != "" { - btn.Tooltip = &model.Text{ + face.tooltip = &model.Text{ BaseElement: model.BaseElement{ ID: model.ID(types.GenerateID()), TypeName: "Texts$Text", @@ -1238,35 +1277,57 @@ func (pb *pageBuilder) buildButtonV3(w *ast.WidgetV3) (*pages.ActionButton, erro if style := w.GetButtonStyle(); style != "" { canonical, ok := pages.CanonicalButtonStyle(style) if !ok { - return nil, mdlerrors.NewValidationf( + return face, mdlerrors.NewValidationf( "button %q: unknown button style %q — valid styles are %s", w.Name, style, strings.Join(pages.ValidButtonStyleList(), ", "), ) } - btn.ButtonStyle = canonical - } - - // Handle Action - if action := w.GetAction(); action != nil { - act, err := pb.buildClientActionV3(action) - if err != nil { - return nil, mdlerrors.NewBackend("build action", err) - } - btn.Action = act + face.style = canonical } // Handle Icon (#602, #1059): one of Mendix's three icon elements. icon, err := buildWidgetIcon(w) if err != nil { - return nil, err + return face, err } - btn.Icon = icon + face.icon = icon + return face, nil +} - if err := pb.registerWidgetName(w.Name, btn.ID); err != nil { +// buildSidebarToggleV3 builds the button that opens and closes a responsive +// layout's sidebar (mendixlabs/mxcli#1220). Its face is an action button's, +// with the same defaults; it has no action. +func (pb *pageBuilder) buildSidebarToggleV3(w *ast.WidgetV3) (*pages.SidebarToggleButton, error) { + tb := &pages.SidebarToggleButton{ + BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ + ID: model.ID(types.GenerateID()), + TypeName: "Forms$SidebarToggleButton", + }, + Name: w.Name, + }, + ButtonStyle: pages.ButtonStyleDefault, + RenderMode: pages.ButtonRenderModeButton, + } + face, err := pb.buildButtonFaceV3(w) + if err != nil { return nil, err } - - return btn, nil + tb.CaptionTemplate, tb.Tooltip, tb.Icon = face.caption, face.tooltip, face.icon + if face.style != "" { + tb.ButtonStyle = face.style + } + switch mode := w.GetStringProp("RenderMode"); { + case mode == "", strings.EqualFold(mode, "Button"): + case strings.EqualFold(mode, "Link"): + tb.RenderMode = pages.ButtonRenderModeLink + default: + return nil, mdlerrors.NewValidationf("sidebartoggle %q: unknown RenderMode %q — valid modes are Button, Link", w.Name, mode) + } + if err := pb.registerWidgetName(w.Name, tb.ID); err != nil { + return nil, err + } + return tb, nil } // buildNavigationListV3 creates a NavigationList widget from V3 syntax. diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index a6a3738ce..ec79654a9 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -826,6 +826,9 @@ type rawWidget struct { // TabCaption: a nameless container whose identity DESCRIBE has to put back. RegionSize int RegionSizeMode string + // RegionToggleMode is a scroll container region's ToggleMode — how a + // sidebar toggle opens it. None (the default) is not printed. + RegionToggleMode string // NavigationProfile is a Forms$NavigationTree's profile, which the document // keeps one level down in MenuSource rather than on the tree. NavigationProfile string diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 22e5fb132..033cce2b4 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -333,6 +333,9 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if w.RegionSizeMode != "" && w.RegionSizeMode != "Auto" { props = append(props, fmt.Sprintf("SizeMode: %s", mdlQuote(ctx, w.RegionSizeMode))) } + if w.RegionToggleMode != "" && w.RegionToggleMode != "None" { + props = append(props, fmt.Sprintf("ToggleMode: %s", mdlQuote(ctx, w.RegionToggleMode))) + } props = appendAppearanceProps(ctx, props, w) if len(w.Children) > 0 { formatWidgetProps(ctx.Output, prefix, header, props, " {\n") @@ -524,14 +527,22 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = appendAppearanceProps(ctx, props, w) formatWidgetProps(ctx.Output, prefix, header, props, "\n") - case "Forms$ActionButton", "Pages$ActionButton": - // RenderType "Link" round-trips as the `linkbutton` keyword. + case "Forms$ActionButton", "Pages$ActionButton", "Forms$SidebarToggleButton", "Pages$SidebarToggleButton": + // RenderType "Link" round-trips as the `linkbutton` keyword. A sidebar + // toggle has no keyword of its own for it, so says it as a property. + toggle := strings.HasSuffix(w.Type, "$SidebarToggleButton") keyword := "actionbutton" - if w.RenderMode == "Link" { + switch { + case toggle: + keyword = "sidebartoggle" + case w.RenderMode == "Link": keyword = "linkbutton" } header := fmt.Sprintf("%s %s", keyword, mdlIdent(w.Name)) props := []string{} + if toggle && w.RenderMode == "Link" { + props = append(props, "RenderMode: Link") + } if w.Caption != "" { props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(ctx, w.Caption))) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 15eacb00e..54182b158 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -157,6 +157,9 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s if sm, ok := region["SizeMode"].(string); ok { child.RegionSizeMode = sm } + if tm, ok := region["ToggleMode"].(string); ok { + child.RegionToggleMode = tm + } child.RegionSize = bsonInt(region["Size"]) for _, c := range getBsonArrayElements(region["Widgets"]) { if cMap, ok := c.(map[string]any); ok { @@ -339,6 +342,18 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.Icon, widget.IconType, widget.IconCode = extractButtonIcon(w) return []rawWidget{widget} + case "Forms$SidebarToggleButton", "Pages$SidebarToggleButton": + // An action button's face without the action (mendixlabs/mxcli#1220). + widget.Caption = extractButtonCaption(ctx, w) + widget.Parameters = extractButtonCaptionParameters(ctx, w) + widget.Tooltip = extractTextsText(ctx, w, "Tooltip") + widget.ButtonStyle = extractButtonStyle(ctx, w) + if rt, ok := w["RenderType"].(string); ok { + widget.RenderMode = rt + } + widget.Icon, widget.IconType, widget.IconCode = extractButtonIcon(w) + return []rawWidget{widget} + case "Forms$Text", "Pages$Text": widget.Content = extractTextCaption(ctx, w) if rm, ok := w["RenderMode"].(string); ok { diff --git a/mdl/executor/cmd_pages_describe_sidebartoggle_test.go b/mdl/executor/cmd_pages_describe_sidebartoggle_test.go new file mode 100644 index 000000000..bfd54e5b4 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_sidebartoggle_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1220 (ako/mxcli#1285): copying Atlas_Core.Atlas_Default with +// `describe layout` → `create layout` lost the responsive sidebar toggle, which +// describe announced as +// +// -- Forms$SidebarToggleButton (sidebarToggle3) -- NOT re-executable: mxcli +// cannot author this widget, so re-running this script would drop it +// +// and Atlas's slide-in navigation on phones and tablets depends on it. + +// storedSidebarToggle is the widget exactly as Atlas_Core.Atlas_Default stores +// it (PedApp, 11.13.0), less its design properties. +func storedSidebarToggle(renderType string) map[string]any { + return map[string]any{ + "$Type": "Forms$SidebarToggleButton", + "Name": "sidebarToggle3", + "Appearance": map[string]any{ + "$Type": "Forms$Appearance", + "Class": "toggle-btn", + "Style": "", + "DynamicClasses": "", + }, + "ButtonStyle": "Primary", + "CaptionTemplate": map[string]any{ + "$Type": "Forms$ClientTemplate", + "Template": map[string]any{ + "$Type": "Texts$Text", + "Items": []any{int32(3), map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": ""}}, + }, + "Parameters": []any{int32(2)}, + }, + "Icon": map[string]any{ + "$Type": "Forms$IconCollectionIcon", + "Image": "Atlas_Core.Atlas_Filled.navigation-menu", + }, + "RenderType": renderType, + "TabIndex": int32(0), + "Tooltip": map[string]any{ + "$Type": "Texts$Text", + "Items": []any{int32(3), map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": "Toggle Menu"}}, + }, + } +} + +func TestDescribeSidebarToggle_RoundTrips(t *testing.T) { + got := describeStoredWidget(t, storedSidebarToggle("Button")) + if strings.Contains(got, "NOT re-executable") { + t.Fatalf("sidebar toggle still described as unauthorable:\n%s", got) + } + for _, want := range []string{ + "sidebartoggle sidebarToggle3", "Tooltip: 'Toggle Menu'", "ButtonStyle: Primary", + "Icon: 'Atlas_Core.Atlas_Filled.navigation-menu'", "Class: 'toggle-btn'", + } { + if !strings.Contains(got, want) { + t.Errorf("description lacks %q:\n%s", want, got) + } + } + if strings.Contains(got, "RenderMode") { + t.Errorf("Button is the default render mode and is not said:\n%s", got) + } + + tb, ok := rebuildMenuWidget(t, got).(*pages.SidebarToggleButton) + if !ok { + t.Fatalf("replay did not build a sidebar toggle from:\n%s", got) + } + if tb.Name != "sidebarToggle3" || tb.Class != "toggle-btn" { + t.Errorf("replayed name/class = %q/%q", tb.Name, tb.Class) + } + if tb.ButtonStyle != pages.ButtonStylePrimary { + t.Errorf("replayed ButtonStyle = %q, want Primary", tb.ButtonStyle) + } + if tb.RenderMode != pages.ButtonRenderModeButton { + t.Errorf("replayed RenderMode = %q, want Button", tb.RenderMode) + } + if tb.Icon == nil || tb.Icon.Image != "Atlas_Core.Atlas_Filled.navigation-menu" { + t.Errorf("replayed Icon = %+v, want the Atlas_Filled navigation-menu icon", tb.Icon) + } + if tb.Tooltip == nil || !strings.Contains(strings.Join(mapValues(tb.Tooltip.Translations), ""), "Toggle Menu") { + t.Errorf("replayed Tooltip = %+v, want 'Toggle Menu'", tb.Tooltip) + } +} + +// Link is the non-default render mode, so it is the one a describer that +// forgets the property loses. +func TestDescribeSidebarToggle_RoundTripsLinkRenderMode(t *testing.T) { + got := describeStoredWidget(t, storedSidebarToggle("Link")) + tb, ok := rebuildMenuWidget(t, got).(*pages.SidebarToggleButton) + if !ok { + t.Fatalf("replay did not build a sidebar toggle from:\n%s", got) + } + if tb.RenderMode != pages.ButtonRenderModeLink { + t.Errorf("replayed RenderMode = %q, want Link; described as:\n%s", tb.RenderMode, got) + } +} + +func mapValues(m map[string]string) []string { + out := make([]string, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} diff --git a/mdl/executor/design_property_keyword_keys_test.go b/mdl/executor/design_property_keyword_keys_test.go index 79bb62997..1404b36d6 100644 --- a/mdl/executor/design_property_keyword_keys_test.go +++ b/mdl/executor/design_property_keyword_keys_test.go @@ -265,6 +265,7 @@ func TestKeywordStorageTypesMatchBuilder(t *testing.T) { navigationtree k_navigationtree (profile: 'Responsive') menubar k_menubar (profile: 'Responsive') simplemenubar k_simplemenubar (profile: 'Responsive') + sidebartoggle k_sidebartoggle placeholder k_placeholder }` prog, errs := visitor.Build(src) @@ -365,7 +366,9 @@ func buildWidgetV3Cases(t *testing.T) []string { // Studio Pro applies too (Atlas declares "Button"; a probe "ActionButton" group // was accepted as well). func TestDesignPropsKeysAreMetamodelClassNames(t *testing.T) { - ancestor := map[string]string{"ActionButton": "Button"} + // SidebarToggleButton is a Button too: Studio Pro stores Atlas's "Button" + // group property Size: Large on Atlas_Core.Atlas_Default's toggle (11.13.0). + ancestor := map[string]string{"ActionButton": "Button", "SidebarToggleButton": "Button"} // Keyed by the Forms$ spelling: the generated metamodel registers only that // prefix. The Pages$ twins in bsonTypeToDesignPropsKey are the same classes. for suffix, key := range storageTypeThemeKeys { diff --git a/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go b/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go new file mode 100644 index 000000000..efaba8c35 --- /dev/null +++ b/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "sort" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1220 (ako/mxcli#1285), the reported workflow end to end on +// the Studio Pro-authored PedApp: describe Atlas_Core.Atlas_Default, execute it +// under a new name, and the copy carries the sidebar toggle Atlas stores — +// with the same keys, so the copy is what Studio Pro would have written. +func TestCopyAtlasDefaultKeepsTheSidebarToggle(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeLayout, Name: ast.QualifiedName{Module: "Atlas_Core", Name: "Atlas_Default"}}); err != nil { + t.Fatalf("describe layout: %v", err) + } + described := languageHeader.ReplaceAllString(out.String(), "") + if strings.Contains(described, "NOT re-executable") { + t.Fatalf("describe still drops a widget:\n%s", described) + } + if !strings.Contains(described, "sidebartoggle sidebarToggle3") { + t.Fatalf("describe does not print the sidebar toggle:\n%s", described) + } + + copied := strings.Replace(described, "create or modify layout Atlas_Core.Atlas_Default", "create layout MyFirstModule.App_Default", 1) + if got := agreeCheck(t, exec, dir, "mdl 1;\n"+copied); len(got) != 0 { + t.Fatalf("check refuses the copy:\n%s", strings.Join(got, "\n")) + } + if err := agreeExec(t, exec, "mdl 1;\n"+copied); err != nil { + t.Fatalf("exec of the copy: %v\n%s", err, copied) + } + + original := sidebarToggles(t, exec, "Atlas_Core.Atlas_Default") + copy := sidebarToggles(t, exec, "MyFirstModule.App_Default") + if len(original) == 0 { + t.Fatal("Atlas_Default has no Forms$SidebarToggleButton in the fixture; the test proves nothing") + } + if len(copy) != len(original) { + t.Fatalf("the copy has %d sidebar toggles, Atlas_Default %d", len(copy), len(original)) + } + for i := range original { + if got, want := rawKeysOf(copy[i]), rawKeysOf(original[i]); got != want { + t.Errorf("toggle %d keys:\n copy: %s\n Studio Pro: %s", i, got, want) + } + for _, k := range []string{"Name", "ButtonStyle", "RenderType"} { + if a, b := copy[i].Lookup(k).String(), original[i].Lookup(k).String(); a != b { + t.Errorf("toggle %d %s = %s, Studio Pro stores %s", i, k, a, b) + } + } + if a, b := copy[i].Lookup("Icon", "Image").String(), original[i].Lookup("Icon", "Image").String(); a != b { + t.Errorf("toggle %d icon = %s, Studio Pro stores %s", i, a, b) + } + } + + // The toggle opens a region whose ToggleMode says how. Without it mxbuild + // reports CE0628 "For the sidebar toggle button to work, the outermost + // widget on the layout must be a scroll container with a region that can + // be toggled" — measured on 11.13.0 against this copy. + origModes, copyModes := storedRegionToggleModes(t, exec, "Atlas_Core.Atlas_Default"), storedRegionToggleModes(t, exec, "MyFirstModule.App_Default") + if origModes["Left"] != "ShrinkContentInitiallyClosed" { + t.Fatalf("fixture's Left region ToggleMode = %q; the test proves nothing", origModes["Left"]) + } + for slot, want := range origModes { + if copyModes[slot] != want { + t.Errorf("region %s ToggleMode = %q in the copy, Studio Pro stores %q", slot, copyModes[slot], want) + } + } + + // And the copy describes as the original does, name aside. + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeLayout, Name: ast.QualifiedName{Module: "MyFirstModule", Name: "App_Default"}}); err != nil { + t.Fatalf("describe copy: %v", err) + } + again := strings.Replace(languageHeader.ReplaceAllString(out.String(), ""), "create or modify layout MyFirstModule.App_Default", "create layout MyFirstModule.App_Default", 1) + if again != copied { + t.Errorf("the copy describes differently:\n--- first\n%s\n--- copy\n%s", copied, again) + } +} + +// sidebarToggles returns the layout's Forms$SidebarToggleButton documents. +func sidebarToggles(t *testing.T, exec *Executor, qn string) []bson.Raw { + t.Helper() + ctx := exec.newExecContext(context.Background()) + u, err := ctx.Backend.GetRawUnitByName("layout", qn) + if err != nil || u == nil { + t.Fatalf("raw layout %s: %v", qn, err) + } + var found []bson.Raw + var walk func(v bson.RawValue) + walk = func(v bson.RawValue) { + switch v.Type { + case bson.TypeEmbeddedDocument: + doc := v.Document() + if doc.Lookup("$Type").StringValue() == "Forms$SidebarToggleButton" { + found = append(found, doc) + } + elems, _ := doc.Elements() + for _, e := range elems { + walk(e.Value()) + } + case bson.TypeArray: + vals, _ := v.Array().Values() + for _, x := range vals { + walk(x) + } + } + } + walk(bson.RawValue{Type: bson.TypeEmbeddedDocument, Value: u.Contents}) + return found +} + +func rawKeysOf(d bson.Raw) string { + elems, _ := d.Elements() + var ks []string + for _, e := range elems { + ks = append(ks, e.Key()) + } + sort.Strings(ks) + return strings.Join(ks, ",") +} + +// storedRegionToggleModes is each scroll container region's ToggleMode, by BSON key. +func storedRegionToggleModes(t *testing.T, exec *Executor, qn string) map[string]string { + t.Helper() + ctx := exec.newExecContext(context.Background()) + u, err := ctx.Backend.GetRawUnitByName("layout", qn) + if err != nil || u == nil { + t.Fatalf("raw layout %s: %v", qn, err) + } + modes := map[string]string{} + var walk func(v bson.RawValue) + walk = func(v bson.RawValue) { + switch v.Type { + case bson.TypeEmbeddedDocument: + doc := v.Document() + elems, _ := doc.Elements() + for _, e := range elems { + if r, ok := e.Value().DocumentOK(); ok && r.Lookup("$Type").StringValue() == "Forms$ScrollContainerRegion" { + modes[e.Key()] = r.Lookup("ToggleMode").StringValue() + } + walk(e.Value()) + } + case bson.TypeArray: + vals, _ := v.Array().Values() + for _, x := range vals { + walk(x) + } + } + } + walk(bson.RawValue{Type: bson.TypeEmbeddedDocument, Value: u.Contents}) + return modes +} diff --git a/mdl/executor/theme_reader.go b/mdl/executor/theme_reader.go index 844a903b4..87d25dde4 100644 --- a/mdl/executor/theme_reader.go +++ b/mdl/executor/theme_reader.go @@ -216,6 +216,7 @@ var mdlKeywordStorageType = map[string]string{ "navigationtree": "Forms$NavigationTree", "menubar": "Forms$MenuBar", "simplemenubar": "Forms$SimpleMenuBar", + "sidebartoggle": "Forms$SidebarToggleButton", "placeholder": "Forms$Placeholder", } @@ -326,8 +327,11 @@ var storageTypeThemeKeys = map[string]string{ "NavigationTree": "NavigationTree", "MenuBar": "MenuBar", "SimpleMenuBar": "SimpleMenuBar", - "Placeholder": "Placeholder", - "SnippetCallWidget": "SnippetCallWidget", + // A Button subclass, so Studio Pro applies Atlas's "Button" group — the + // Size: Large Atlas_Default's toggle carries comes from it. + "SidebarToggleButton": "Button", + "Placeholder": "Placeholder", + "SnippetCallWidget": "SnippetCallWidget", } // bsonTypeToDesignPropsKey maps BSON $Type values to design-properties.json keys: diff --git a/mdl/executor/validate_page_context.go b/mdl/executor/validate_page_context.go index 52d28891b..e10ac528a 100644 --- a/mdl/executor/validate_page_context.go +++ b/mdl/executor/validate_page_context.go @@ -238,7 +238,7 @@ var alwaysNamedKinds = map[string]bool{ "container": true, "customcontainer": true, "groupbox": true, "tabcontainer": true, "layoutgrid": true, "navigationlist": true, "staticimage": true, "dynamicimage": true, "image": true, "scrollcontainer": true, "navigationtree": true, "menubar": true, - "simplemenubar": true, "textfilter": true, "numberfilter": true, "dropdownfilter": true, + "simplemenubar": true, "sidebartoggle": true, "textfilter": true, "numberfilter": true, "dropdownfilter": true, "datefilter": true, "dropdownsort": true, } diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index e62f53dfc..7b9de4977 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -1048,6 +1048,9 @@ var staticWidgetKnownProps = func() map[string]bool { // menu source and orientation of navigationtree / menubar / // simplemenubar, and a scroll-container region's size mode. "ShowFooter", "Menu", "Profile", "Orientation", "SizeMode", + // a scroll-container region's toggle mode, which a sidebartoggle opens + // (mendixlabs/mxcli#1220). + "ToggleMode", // a layout-grid row's and column's alignment, read by // buildLayoutGridRowV3 / buildLayoutGridColumnV3 and emitted by describe. "VerticalAlignment", "HorizontalAlignment", "SpacingBetweenColumns", @@ -1085,7 +1088,7 @@ var staticWidgetKnownPropList = func() []string { "Editable", "Tooltip", "DynamicClasses", "WidthUnit", "HeightUnit", "DesktopColumns", "TabletColumns", "PhoneColumns", "PageSize", "Pagination", "Image", "DefaultImage", "DisplayAs", "OnClickType", "ShowFooter", "Menu", - "Profile", "Orientation", "SizeMode") + "Profile", "Orientation", "SizeMode", "ToggleMode") return list }() diff --git a/mdl/executor/widget_keywords.go b/mdl/executor/widget_keywords.go index d038d2e34..cfb1bbc86 100644 --- a/mdl/executor/widget_keywords.go +++ b/mdl/executor/widget_keywords.go @@ -21,7 +21,7 @@ var builderOwnedWidgetKeywords = map[string]bool{ "statictext": true, "dynamictext": true, "title": true, "label": true, "button": true, "actionbutton": true, "linkbutton": true, "tabcontainer": true, "tabpage": true, "groupbox": true, "scrollcontainer": true, "region": true, - "navigationtree": true, "menubar": true, "simplemenubar": true, + "navigationtree": true, "menubar": true, "simplemenubar": true, "sidebartoggle": true, "placeholder": true, "radiobuttons": true, "navigationlist": true, "item": true, "snippetcall": true, "footer": true, "header": true, "controlbar": true, "template": true, "filter": true, "staticimage": true, "dynamicimage": true, diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index c81464239..8567764e0 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -362,6 +362,7 @@ SCROLLCONTAINER: S C R O L L C O N T A I N E R; SCROLLREGION: R E G I O N; MENUBAR: M E N U B A R; SIMPLEMENUBAR: S I M P L E M E N U B A R; +SIDEBARTOGGLE: S I D E B A R T O G G L E; NAVIGATIONTREE: N A V I G A T I O N T R E E; SNIPPETCALL: S N I P P E T C A L L; diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index c3071c405..b87a53105 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -449,6 +449,7 @@ widgetTypeV3 | NAVIGATIONTREE | MENUBAR | SIMPLEMENUBAR // Atlas phone bottom bar: renders a menu document (#573) + | SIDEBARTOGGLE // Atlas's responsive sidebar toggle button (mendixlabs/mxcli#1220) // Object-list container keywords for pluggable widgets (Phase 1 — #538). // Each is the singular form of a Type:"object"+IsList:true widget property // (e.g. Accordion groups → GROUP). Routed at executor time via the parent diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 9e78c481c..ca26117a6 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -714,7 +714,7 @@ keyword // Layout structure. SCROLLREGION is spelled `region`, an ordinary enough // word that leaving it out of this rule would make `region` unusable as an // attribute or widget name anywhere in MDL. - | SCROLLCONTAINER | SCROLLREGION | NAVIGATIONTREE | MENUBAR | SIMPLEMENUBAR + | SCROLLCONTAINER | SCROLLREGION | NAVIGATIONTREE | MENUBAR | SIMPLEMENUBAR | SIDEBARTOGGLE // Object-list container keywords for pluggable widgets (#538) | GROUP | CUSTOMITEM | MARKER | DYNAMICMARKER | SERIES | LINE | SCALECOLOR | CUSTOMBUTTON | ALLOWEDFILEFORMAT diff --git a/modelsdk/gen/keyaudit_test.go b/modelsdk/gen/keyaudit_test.go index 1671f108d..7d3707fd9 100644 --- a/modelsdk/gen/keyaudit_test.go +++ b/modelsdk/gen/keyaudit_test.go @@ -94,7 +94,6 @@ var knownWrongKeys = []keyMismatch{ {"Forms$ReferenceSetSelector", "XPathConstraint", "SelectableXPathConstraint"}, {"Forms$SearchBar", "Items", "NewButtons"}, {"Forms$SelectButton", "Caption", "CaptionTemplate"}, - {"Forms$SidebarToggleButton", "Caption", "CaptionTemplate"}, {"Forms$Table", "Columns", "ColumnWidths"}, {"Forms$TableColumn", "Width", "Value"}, diff --git a/modelsdk/gen/pages/types.go b/modelsdk/gen/pages/types.go index 568e79474..6db4d5053 100644 --- a/modelsdk/gen/pages/types.go +++ b/modelsdk/gen/pages/types.go @@ -23076,7 +23076,7 @@ func (o *SidebarToggleButton) InitFromRaw(raw bson.Raw) { if child, err := codec.DecodeChild(raw, "AccessibilitySettings"); err == nil { o.accessibilitySettings.SetFromDecode(child) } - if child, err := codec.DecodeChild(raw, "Caption"); err == nil { + if child, err := codec.DecodeChild(raw, "CaptionTemplate"); err == nil { o.caption.SetFromDecode(child) } if child, err := codec.DecodeChild(raw, "Tooltip"); err == nil { @@ -32772,7 +32772,10 @@ func initSidebarToggleButton() *SidebarToggleButton { o.conditionalVisibilitySettings.Bind(&o.Base, 5) o.accessibilitySettings = property.NewPart[element.Element]("AccessibilitySettings") o.accessibilitySettings.Bind(&o.Base, 6) - o.caption = property.NewPart[element.Element]("Caption") + // STORAGE-NAME OVERRIDE: Studio Pro stores the caption as CaptionTemplate + // (Atlas_Core.Atlas_Default, 11.13.0); "Caption" is the SDK name + // (mendixlabs/mxcli#1220). + o.caption = property.NewPart[element.Element]("CaptionTemplate") o.caption.Bind(&o.Base, 7) o.tooltip = property.NewPart[element.Element]("Tooltip") o.tooltip.Bind(&o.Base, 8) diff --git a/sdk/pages/pages_widgets_advanced.go b/sdk/pages/pages_widgets_advanced.go index c0361b248..7c1f01ba0 100644 --- a/sdk/pages/pages_widgets_advanced.go +++ b/sdk/pages/pages_widgets_advanced.go @@ -89,6 +89,23 @@ type SimpleMenuBar struct { MenuSource MenuSource `json:"menuSource,omitempty"` } +// SidebarToggleButton is the button that opens and closes a responsive +// layout's sidebar — Atlas's slide-in navigation on phones and tablets +// (mendixlabs/mxcli#1220). +// +// Measured on Atlas_Core.Atlas_Default (11.13.0): Appearance, ButtonStyle, +// CaptionTemplate, ConditionalVisibilitySettings, Icon, Name, RenderType, +// TabIndex, Tooltip — an action button's keys without the action, which is +// fixed: the button toggles the sidebar. +type SidebarToggleButton struct { + BaseWidget + CaptionTemplate *ClientTemplate `json:"captionTemplate,omitempty"` + Tooltip *model.Text `json:"tooltip,omitempty"` + Icon *Icon `json:"icon,omitempty"` + ButtonStyle ButtonStyle `json:"buttonStyle,omitempty"` + RenderMode ButtonRenderMode `json:"renderMode,omitempty"` +} + // MenuOrientation represents menu orientation. type MenuOrientation string diff --git a/sdk/pages/pages_widgets_container.go b/sdk/pages/pages_widgets_container.go index 508471e32..f3ee2ec15 100644 --- a/sdk/pages/pages_widgets_container.go +++ b/sdk/pages/pages_widgets_container.go @@ -130,8 +130,13 @@ type ScrollContainerRegion struct { Slot ScrollContainerSlot `json:"slot"` Size int `json:"size,omitempty"` SizeMode string `json:"sizeMode,omitempty"` - Class string `json:"class,omitempty"` - Widgets []Widget `json:"widgets,omitempty"` + // ToggleMode is how a sidebar toggle button opens and closes the region: + // None, PushContentAside, SlideOverContent, ShrinkContentInitiallyOpen or + // ShrinkContentInitiallyClosed. Empty writes None. A layout with a toggle + // and no toggleable region is CE0628 (mendixlabs/mxcli#1220). + ToggleMode string `json:"toggleMode,omitempty"` + Class string `json:"class,omitempty"` + Widgets []Widget `json:"widgets,omitempty"` } // ScrollContainerSlot names one of the five regions. From f16b3562c33e650db5b91782302b16e4026534d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:17:31 +0000 Subject: [PATCH 19/22] fix: a one-to-one association member in a message definition no longer fails CE6524 The reverse traversal of a Reference was always stored as a list (MaxOccurs -1). A Reference owned by both ends is one-to-one, so from the TO side it is a single object too; storing -1 made mx check report CE6524 "The occurrence of '...' has changed" on CREATE, CREATE OR MODIFY and ALTER ... ADD MEMBER. Measured on 11.12.1: the repro gives 2x CE6524 before, 0 errors after; the forward definition in the same script is the control (clean on both). Fixes ako/mxcli#1311 (mendixlabs/mxcli#1386). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnpNHdpFqu1sPL7yNAQgTh --- ...finition-one-to-one-occurrence-ce6524.json | 1 + ...ssage-definition-one-to-one-occurrence.mdl | 47 ++++++++++++++++ mdl/executor/cmd_messagedefinitions.go | 18 +++++-- mdl/executor/cmd_messagedefinitions_test.go | 54 ++++++++++++++++++- 4 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-message-definition-one-to-one-occurrence-ce6524.json create mode 100644 mdl-examples/bug-tests/1386-message-definition-one-to-one-occurrence.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-message-definition-one-to-one-occurrence-ce6524.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-message-definition-one-to-one-occurrence-ce6524.json new file mode 100644 index 000000000..563dcdfd8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-message-definition-one-to-one-occurrence-ce6524.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "A message definition member over a one-to-one association (`type Reference owner Both`) fails `mx check` with CE6524 \"The occurrence of 'Shop.CustomerProfile_Customer' has changed\" — on CREATE, on CREATE OR MODIFY of unchanged describe output, and on ALTER … ADD MEMBER; `exec` and `describe` look clean", "cause": "`resolveAssociationCardinality` returned MaxOccurs -1 for every REVERSE traversal (holder is the TO end). Owner Both on a Reference makes it one-to-one, so the reverse is a single object (1) too", "file": "`mdl/executor/cmd_messagedefinitions.go` (`resolveAssociationCardinality`)", "insight": "The cardinality table had two axes measured (direction, Reference vs ReferenceSet) and a third that the corpus never exercised: OWNER. When a rule's measurement corpus has no example of a property (no ReferenceSet then, no Owner Both now), the rule is unmeasured on that property, not exceptionless. CE6524 is a reliable oracle for the occurrence — mxbuild recomputes it from the domain model — so the fix is settled by mx check, no Studio Pro reference needed: base build 2x CE6524, fixed 0, with the forward definition in the same script as the control (clean on both). Owner is read from the association already in hand; ReferenceSet Owner Both stays -1 both ways. Tests `TestOneToOneAssociationIsSingleInBothDirections`; repro `mdl-examples/bug-tests/1386-message-definition-one-to-one-occurrence.mdl`", "refs": ["mendixlabs/mxcli#1386", "ako/mxcli#1311"], "ce": ["CE6524"]} diff --git a/mdl-examples/bug-tests/1386-message-definition-one-to-one-occurrence.mdl b/mdl-examples/bug-tests/1386-message-definition-one-to-one-occurrence.mdl new file mode 100644 index 000000000..d0413d6b1 --- /dev/null +++ b/mdl-examples/bug-tests/1386-message-definition-one-to-one-occurrence.mdl @@ -0,0 +1,47 @@ +mdl 1; +-- Bug mendixlabs/mxcli#1386 (ako/mxcli#1311): a message definition member over a +-- one-to-one association gets the wrong occurrence. +-- +-- Symptom, verbatim: "[CE6524] The domain model has changed and is no longer +-- consistent with the message definition. ... Details: - The occurrence of +-- 'Shop.CustomerProfile_Customer' has changed" +-- +-- Cause: resolveAssociationCardinality (mdl/executor/cmd_messagedefinitions.go) +-- made every reverse traversal a list (MaxOccurs -1). A Reference owned by BOTH +-- ends is one-to-one, so from the TO side it is a single object (1) as well. +-- CREATE, CREATE OR MODIFY (re-executing describe output) and ALTER … ADD MEMBER +-- all resolve through it. +-- +-- Measured on mxbuild 11.12.1: before the fix this script gives 2 errors (CE6524 +-- on each Customer definition; the forward Profile definition was already right), +-- after it 0. Run it: +-- +-- mxcli exec mdl-examples/bug-tests/1386-message-definition-one-to-one-occurrence.mdl -p app.mpr +-- mxcli docker check -p app.mpr + +create module Shop1386; +create entity Shop1386.Customer ( Name: String(100) ); +create entity Shop1386.CustomerProfile ( Notes: String(200) ); +create association Shop1386.CustomerProfile_Customer + from Shop1386.CustomerProfile to Shop1386.Customer type Reference owner Both; + +-- Reverse (Customer is the TO end) — the reported shape — and forward. +create message definition collection Shop1386.MD_Customer { + definition Customer for Shop1386.Customer as 'Customers' { + Name, + Shop1386.CustomerProfile_Customer/Shop1386.CustomerProfile { Notes } + } + definition Profile for Shop1386.CustomerProfile as 'Profiles' { + Notes, + Shop1386.CustomerProfile_Customer/Shop1386.Customer { Name } + } +}; + +-- The ALTER path resolves the same way. +create message definition collection Shop1386.MD_Alter { + definition Customer for Shop1386.Customer as 'Customers' { Name } +}; +alter message definition Shop1386.MD_Alter.Customer + add member Shop1386.CustomerProfile_Customer/Shop1386.CustomerProfile { Notes }; + +describe message definition collection Shop1386.MD_Customer; diff --git a/mdl/executor/cmd_messagedefinitions.go b/mdl/executor/cmd_messagedefinitions.go index 2c236f743..1b2bf7944 100644 --- a/mdl/executor/cmd_messagedefinitions.go +++ b/mdl/executor/cmd_messagedefinitions.go @@ -290,9 +290,10 @@ func buildMessageMember(ctx *ExecContext, m *ast.MessageMemberDef, holderQN, col // It is a function of the direction of traversal AND the association's type, // and the second half was learned the hard way (ako/mxcli-rest FINDINGS #60). // -// forward (holder is FROM) reverse (holder is TO) -// Reference 1 -1 -// ReferenceSet -1 -1 +// forward (holder is FROM) reverse (holder is TO) +// Reference 1 -1 +// Reference, Owner Both 1 1 +// ReferenceSet -1 -1 // // The reverse is always a list: many holders point at one target, so from the // target's side the element repeats. The forward direction is where the type @@ -310,6 +311,11 @@ func buildMessageMember(ctx *ExecContext, m *ast.MessageMemberDef, holderQN, col // changed" on the definition, plus CE0295 on any object mapping element bound // to it. Measured on ako/mxcli-rest at 11.13.0 against a 0-error baseline. // +// The one-to-one row is the same rule seen from the other side: Owner Both on a +// Reference means each target has at most one holder, so the reverse is single +// too. Storing -1 there is CE6524 on the definition (mendixlabs/mxcli#1386); +// measured on 11.12.1, where 1 is what clears it. +// // An association that connects the two entities in NEITHER direction is refused // rather than defaulted. A wrong cardinality is worse than a refusal: for a // Reference it builds clean and exposes a list as a single object. @@ -330,7 +336,11 @@ func resolveAssociationCardinality(ctx *ExecContext, assocQN, holderQN, targetQN return 1, nil case toQN == holderQN && fromQN == targetQN: // The reverse: many holders point at one target, so from the target's - // side the element repeats. + // side the element repeats — unless the Reference is owned by both + // ends, which makes it one-to-one and single this way too. + if assoc.Type == domainmodel.AssociationTypeReference && assoc.Owner == domainmodel.AssociationOwnerBoth { + return 1, nil + } return -1, nil } return 0, mdlerrors.NewValidation(fmt.Sprintf( diff --git a/mdl/executor/cmd_messagedefinitions_test.go b/mdl/executor/cmd_messagedefinitions_test.go index 4db80d2d4..894b866f4 100644 --- a/mdl/executor/cmd_messagedefinitions_test.go +++ b/mdl/executor/cmd_messagedefinitions_test.go @@ -58,9 +58,19 @@ func mdFixture(t *testing.T) (*ExecContext, *model.MessageDefinitionCollection) Type: domainmodel.AssociationTypeReferenceSet} tags.ID = nextID("tags") + profile := &domainmodel.Entity{Name: "CustomerProfile", Persistable: true} + profile.ID = nextID("profile") + profile.Attributes = []*domainmodel.Attribute{mkAttr("Notes", &domainmodel.StringAttributeType{})} + + // One-to-one: a Reference owned by BOTH ends, so each Customer has at most + // one profile as surely as each profile has one Customer. + oneToOne := &domainmodel.Association{Name: "CustomerProfile_Customer", ParentID: profile.ID, ChildID: customer.ID, + Type: domainmodel.AssociationTypeReference, Owner: domainmodel.AssociationOwnerBoth} + oneToOne.ID = nextID("o2o") + dm := &domainmodel.DomainModel{ContainerID: mod.ID, - Entities: []*domainmodel.Entity{base, order, customer, tag}, - Associations: []*domainmodel.Association{assoc, tags}, + Entities: []*domainmodel.Entity{base, order, customer, tag, profile}, + Associations: []*domainmodel.Association{assoc, tags, oneToOne}, } dm.ID = nextID("dm") h := mkHierarchy(mod) @@ -199,6 +209,46 @@ func TestReferenceSetIsAListInBothDirections(t *testing.T) { } } +// mendixlabs/mxcli#1386: "When a message definition exposes a one-to-one +// association (`TYPE Reference OWNER Both`), mxcli stores an occurrence for that +// element that differs from what Studio Pro derives" — mx check then reports +// CE6524 "The occurrence of 'Shop.CustomerProfile_Customer' has changed". +// +// The reverse of a Reference is a list only because many holders can point at +// one target. Owner Both makes it one-to-one, so from the TO side it is one +// object too. +func TestOneToOneAssociationIsSingleInBothDirections(t *testing.T) { + // The reported shape: Customer is the TO entity, reaching its profile. + c, err := runCreate(t, mdCreate(&ast.MessageDefinitionDef{ + Name: "Customer", + Entity: ast.QualifiedName{Module: "Sales", Name: "Customer"}, + Members: []*ast.MessageMemberDef{assocMember("CustomerProfile_Customer", "CustomerProfile", attrMember("Notes"))}, + })) + if err != nil { + t.Fatalf("create: %v", err) + } + child := c.Definitions[0].Root.Children[0] + if child.MaxOccurs != 1 { + t.Errorf("Customer -> CustomerProfile MaxOccurs = %d, want 1: a one-to-one association is single from the TO side too (CE6524)", child.MaxOccurs) + } + if child.ExposedItemName != "" { + t.Errorf("ExposedItemName = %q, want empty for a single object", child.ExposedItemName) + } + + // Forward was already single and must stay so. + c, err = runCreate(t, mdCreate(&ast.MessageDefinitionDef{ + Name: "Profile", + Entity: ast.QualifiedName{Module: "Sales", Name: "CustomerProfile"}, + Members: []*ast.MessageMemberDef{assocMember("CustomerProfile_Customer", "Customer", attrMember("Name"))}, + })) + if err != nil { + t.Fatalf("create: %v", err) + } + if got := c.Definitions[0].Root.Children[0].MaxOccurs; got != 1 { + t.Errorf("CustomerProfile -> Customer MaxOccurs = %d, want 1", got) + } +} + // TestAssociationThatConnectsNeitherWayIsRefused pins the refusal. Defaulting // to 1 would build, and be wrong. func TestAssociationThatConnectsNeitherWayIsRefused(t *testing.T) { From 67ed266c1eae0b25ada9f565fd5c3982b1786df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:17:30 +0000 Subject: [PATCH 20/22] fix: create external entities names entities after the entity type, as Studio Pro does `create external entities from M.Client into M` named a top-level entity after its OData entity set (`ModA.Parts`); Studio Pro names it after the entity type (`ModA.Part`), so scripts written against that name failed with "entity does not exist". Re-import resolves an existing entity by service + entity set before name, so a module imported by an older mxcli is updated in place and keeps its entity-set name instead of growing a type-named twin mapping the same set. The navigation-association and primitive-collection passes use the same resolver. Measured on 11.12.1: base reproduces "external entity not found: Issue1432.Product"; fixed creates Product/Category, mx check 0 errors; a base-imported project re-imported by the fix reports 0 created, 2 updated. Fixes ako/mxcli#1319 (mendixlabs/mxcli#1432). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnpNHdpFqu1sPL7yNAQgTh --- ...ernal-entities-named-after-entity-set.json | 1 + docs-site/src/reference/odata/README.md | 6 + ...ernal-entities-named-after-entity-type.mdl | 46 +++++ ...-external-entity-create-change-locally.mdl | 8 +- .../odata-1020-external-action-types.mdl | 2 +- .../doctype-tests/10-odata-examples.mdl | 6 +- mdl/executor/cmd_contract.go | 67 +++---- mdl/executor/cmd_contract_complextype_test.go | 12 +- mdl/executor/cmd_contract_entity_name_test.go | 179 ++++++++++++++++++ .../cmd_contract_key_updatable_test.go | 6 +- mdl/executor/cmd_contract_test.go | 2 +- 11 files changed, 285 insertions(+), 50 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-external-entities-named-after-entity-set.json create mode 100644 mdl-examples/bug-tests/1432-external-entities-named-after-entity-type.mdl create mode 100644 mdl/executor/cmd_contract_entity_name_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-external-entities-named-after-entity-set.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-external-entities-named-after-entity-set.json new file mode 100644 index 000000000..4449bf16b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-external-entities-named-after-entity-set.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`create external entities from M.Client into M` names a top-level entity after the OData entity SET (`ModA.Parts`) where Studio Pro names it after the entity TYPE (`ModA.Part`); a later script using `ModA.Part` fails with \"entity does not exist\"", "cause": "`createExternalEntities` chose `entitySet.Name` for every top-level entity, and the navigation-association and primitive-collection passes re-derived the same name to find their ends. `describe contract entity … format mdl` already emitted the type name, so mxcli disagreed with itself as well as with Studio Pro", "file": "`mdl/executor/cmd_contract.go` (`createExternalEntities`, `createNavigationAssociations`, `createPrimitiveCollectionNPEs`, new `importedEntityFor`)", "insight": "**The evidence for Studio Pro's naming was already in the repo**: the TripPin measurement tables in cmd_contract.go and the complex-type tests list `Person (entity set People)` — a Studio Pro-generated entity named after the type. Grep the measured tables before asking for a Studio Pro reference. **A rename of what an import produces is a re-import hazard, not just a cosmetic fix**: matching the existing entity by the NEW name makes every project imported by an older mxcli grow a type-named twin mapping the same entity set, and mx check does not complain (both entities are valid). So the upsert resolves by remote identity first (Source=Rest$ODataRemoteEntitySource + RemoteServiceName + RemoteEntitySet, all read back by the modelsdk reader) and keeps the old name, which pages and microflows already use. Name resolution happened in THREE passes; changing only the first leaves the association pass looking up the old name and silently creating no associations — a test that asserts the association exists catches it. Test fixtures need nav-property names that do not collide with entity names, or uniqueAssocName suffixes them (`Supplier_2`) and the assertion fails for an unrelated reason. Measured on 11.12.1: base build reproduces `external entity not found: Issue1432.Product`; fixed build creates Product/Category, mx check 0 errors; a project imported by the base build and re-imported by the fixed one reports `0 created, 2 updated` and keeps Products/Categories. Tests `cmd_contract_entity_name_test.go`; repro `mdl-examples/bug-tests/1432-external-entities-named-after-entity-type.mdl`", "refs": ["mendixlabs/mxcli#1432", "ako/mxcli#1319"]} diff --git a/docs-site/src/reference/odata/README.md b/docs-site/src/reference/odata/README.md index c0429592f..b33716c70 100644 --- a/docs-site/src/reference/odata/README.md +++ b/docs-site/src/reference/odata/README.md @@ -46,6 +46,12 @@ CREATE EXTERNAL ENTITIES FROM Module.Service ENTITIES (Customer, Order); CREATE OR MODIFY EXTERNAL ENTITIES FROM Module.Service; ``` +Each entity is named after its OData **entity type**, as Studio Pro names it: an +entity type `Part` exposed as entity set `Parts` becomes `Module.Part`. The entity +set is kept as the entity's remote entity set. A re-import finds an entity by the +service and entity set it maps first, so an entity that an earlier mxcli version +named after the set (`Module.Parts`) is updated in place and keeps its name. + ### Complex types are flattened The Mendix domain model has no complex types. A property typed as an OData diff --git a/mdl-examples/bug-tests/1432-external-entities-named-after-entity-type.mdl b/mdl-examples/bug-tests/1432-external-entities-named-after-entity-type.mdl new file mode 100644 index 000000000..d5a886a26 --- /dev/null +++ b/mdl-examples/bug-tests/1432-external-entities-named-after-entity-type.mdl @@ -0,0 +1,46 @@ +mdl 1; +-- Bug mendixlabs/mxcli#1432 (ako/mxcli#1319): `create external entities from +-- M.Client into M` names entities after the entity set, not the entity type. +-- +-- Symptom, verbatim: "Bulk external-entity creation names each entity after the +-- OData entity set (`Parts`), while Studio Pro names it after the entity type +-- (`Part`). A script that refers to the Studio Pro name then fails." +-- +-- Cause: createExternalEntities (mdl/executor/cmd_contract.go) chose the entity +-- set name for every top-level entity. The single-entity path +-- (`describe contract entity … format mdl`) already used the type name, so the +-- two disagreed with each other as well as with Studio Pro. +-- +-- An entity an older mxcli imported under the set name is still found on +-- re-import — by service and entity set, not by name — and keeps its name, so +-- the `create or modify` below updates rather than duplicates. +-- +-- Manual verification (needs a project; the metadata file ships with the repo, +-- and MetadataUrl resolves relative to the project directory): +-- +-- mkdir -p /path/to/app/mdl-examples/odata-local-metadata +-- cp mdl-examples/odata-local-metadata/sample-metadata.xml /path/to/app/mdl-examples/odata-local-metadata/ +-- mxcli exec mdl-examples/bug-tests/1432-external-entities-named-after-entity-type.mdl -p app.mpr +-- +-- Expect `Issue1432.Product` and `Issue1432.Category` (entity sets Products, +-- Categories). Before the fix they were Issue1432.Products and +-- Issue1432.Categories, and the describe below failed with "entity does not exist". + +create module Issue1432; + +create constant Issue1432.SvcUrl + ( Type: string, DefaultValue: 'https://services.odata.org/V4/Northwind/Northwind.svc/' ); + +create consumed odata service Issue1432.Sample ( + ODataVersion: OData4, + MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml', + ServiceUrl: '@Issue1432.SvcUrl' +); + +create external entities from Issue1432.Sample into Issue1432; + +describe external entity Issue1432.Product; +describe external entity Issue1432.Category; + +-- A re-import updates the same two entities: 0 created, 2 updated. +create or modify external entities from Issue1432.Sample into Issue1432; diff --git a/mdl-examples/bug-tests/782-external-entity-create-change-locally.mdl b/mdl-examples/bug-tests/782-external-entity-create-change-locally.mdl index 3113a42db..66b66de16 100644 --- a/mdl-examples/bug-tests/782-external-entity-create-change-locally.mdl +++ b/mdl-examples/bug-tests/782-external-entity-create-change-locally.mdl @@ -47,13 +47,13 @@ create consumed odata service Issue782.Sample ( create external entities from Issue782.Sample into Issue782; -- (1) The entity must read back as external at all. -describe external entity Issue782.Products; +describe external entity Issue782.Product; -- The reported flow: turn the flag on, and it must stick. -alter entity Issue782.Products set ( AllowCreateChangeLocally: true ); -describe external entity Issue782.Products; +alter entity Issue782.Product set ( AllowCreateChangeLocally: true ); +describe external entity Issue782.Product; -- (2) A re-import refreshes the contract-derived capabilities but must not reset -- the local-changes flag. create or modify external entities from Issue782.Sample into Issue782; -describe external entity Issue782.Products; +describe external entity Issue782.Product; diff --git a/mdl-examples/bug-tests/odata-1020-external-action-types.mdl b/mdl-examples/bug-tests/odata-1020-external-action-types.mdl index 14756a01f..5fcc6700e 100644 --- a/mdl-examples/bug-tests/odata-1020-external-action-types.mdl +++ b/mdl-examples/bug-tests/odata-1020-external-action-types.mdl @@ -68,7 +68,7 @@ create consumed odata service Ext.TripPin ( ServiceUrl: @Ext.SvcUrl ); --- Imports BOTH kinds of entity: the entity-set-backed one (People) and the +-- Imports BOTH kinds of entity: the entity-set-backed one (Person) and the -- action's return type (Airport), which has no entity set and is stored as -- Rest$ODataEntityTypeSource. create external entities from Ext.TripPin into Ext; diff --git a/mdl-examples/doctype-tests/10-odata-examples.mdl b/mdl-examples/doctype-tests/10-odata-examples.mdl index f2f908383..2372f3845 100644 --- a/mdl-examples/doctype-tests/10-odata-examples.mdl +++ b/mdl-examples/doctype-tests/10-odata-examples.mdl @@ -560,9 +560,9 @@ create consumed odata service TripPinClient."TripPinApiClient" ( /** * Level 10.2: Bulk import all entity types from the TripPin contract. - * Creates one external entity per OData entity type. Top-level entity - * sets (Airlines, Airports, People) use the set name; derived/contained - * types (Flight, PlanItem, Trip, etc.) use the type name. + * Creates one external entity per OData entity type, named after the + * entity TYPE the way Studio Pro names it: the People set becomes Person, + * Airlines Airline, Airports Airport (mendixlabs/mxcli#1432). */ create external entities from TripPinClient."TripPinApiClient"; diff --git a/mdl/executor/cmd_contract.go b/mdl/executor/cmd_contract.go index 0064a5f12..6834229d7 100644 --- a/mdl/executor/cmd_contract.go +++ b/mdl/executor/cmd_contract.go @@ -526,11 +526,6 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) return mdlerrors.NewBackend("get domain model", err) } - // Index existing entities by name for upsert - existing := make(map[string]*domainmodel.Entity) - for _, ent := range dm.Entities { - existing[ent.Name] = ent - } // What new entities are placed against: the module as read, plus every // entity this import creates, so they clear each other as well. placed := &domainmodel.DomainModel{ @@ -568,15 +563,20 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) } isTopLevel := entitySetName != "" - // Mendix entity name: entity set name when present (e.g. "People"), - // otherwise the type name (e.g. "PlanItem", "Flight", "Trip"). + // Mendix entity name: the entity TYPE name, as Studio Pro names it + // ("Part" for entity set "Parts", "Person" for "People"). Naming it + // after the set broke every script written against Studio Pro's name + // (mendixlabs/mxcli#1432). An entity an older mxcli imported under + // the set name is still found, by its remote identity, and keeps the + // name pages and microflows already use. + existingEntity := importedEntityFor(dm, serviceRef, entitySetName, et.Name) mendixName := et.Name - if isTopLevel { - mendixName = entitySetName + if existingEntity != nil { + mendixName = existingEntity.Name } // Apply entity name filter (matched against type name OR entity set name) - if len(filterSet) > 0 && !filterSet[strings.ToLower(et.Name)] && !filterSet[strings.ToLower(mendixName)] { + if len(filterSet) > 0 && !filterSet[strings.ToLower(et.Name)] && !filterSet[strings.ToLower(entitySetName)] { continue } @@ -808,7 +808,7 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) attrs = append(attrs, attr) } - if existingEntity, ok := existing[mendixName]; ok { + if existingEntity != nil { if !s.CreateOrModify { fmt.Fprintf(ctx.Output, " SKIPPED: %s.%s (already exists; use create or modify to update)\n", targetModule, mendixName) skipped++ @@ -921,15 +921,8 @@ func createPrimitiveCollectionNPEs( // Lookup parent Mendix entity by EDMX type qualified name. parentByQN := make(map[string]*domainmodel.Entity) for qn, et := range typeByQualified { - mendixName := et.Name - if es := esMap[qn]; es != "" { - mendixName = es - } - for _, ent := range dm.Entities { - if ent.Name == mendixName { - parentByQN[qn] = ent - break - } + if ent := importedEntityFor(dm, serviceRef, esMap[qn], et.Name); ent != nil { + parentByQN[qn] = ent } } @@ -1051,6 +1044,26 @@ func isInheritedProperty(et *types.EdmEntityType, propName string, byQN map[stri return true } +// importedEntityFor returns the entity an import of this OData entity type +// writes to: the one already mapped to the same entity set of the same service, +// else the one carrying the type's name. +// +// The remote identity comes first because mxcli once named a top-level entity +// after its entity SET (mendixlabs/mxcli#1432). Matching on the name alone would +// make a re-import of such a module create a type-named twin mapping the same +// set, and renaming the old one would break everything written against it. +func importedEntityFor(dm *domainmodel.DomainModel, serviceRef, entitySetName, typeName string) *domainmodel.Entity { + if entitySetName != "" { + for _, ent := range dm.Entities { + if ent.Source == "Rest$ODataRemoteEntitySource" && + ent.RemoteServiceName == serviceRef && ent.RemoteEntitySet == entitySetName { + return ent + } + } + } + return findEntityByName(dm, typeName) +} + // findEntityByName returns a domain model entity by name, or nil if not found. func findEntityByName(dm *domainmodel.DomainModel, name string) *domainmodel.Entity { for _, ent := range dm.Entities { @@ -1136,20 +1149,10 @@ func createNavigationAssociations( esByType[es.EntityType] = es } // Build a lookup from EDMX type qualified name → existing Mendix entity. - // An entity type matches by its EntitySet name when present, otherwise by - // its bare type name. mendixByType := make(map[string]*domainmodel.Entity) for qn, et := range typeByQualified { - entitySetName := esMap[qn] - mendixName := et.Name - if entitySetName != "" { - mendixName = entitySetName - } - for _, ent := range dm.Entities { - if ent.Name == mendixName { - mendixByType[qn] = ent - break - } + if ent := importedEntityFor(dm, serviceRef, esMap[qn], et.Name); ent != nil { + mendixByType[qn] = ent } } diff --git a/mdl/executor/cmd_contract_complextype_test.go b/mdl/executor/cmd_contract_complextype_test.go index 912afb487..1bca7548c 100644 --- a/mdl/executor/cmd_contract_complextype_test.go +++ b/mdl/executor/cmd_contract_complextype_test.go @@ -374,7 +374,7 @@ const trippinShapedContract = ` // while the SAME `Address` reached through `Person.HomeAddress`, typed `Location` // directly, is accepted — so the line is inheritance, not the path syntax. func TestCreateExternalEntities_DoesNotFlattenInheritedComplexProperties(t *testing.T) { - airport, out := importOne(t, trippinShapedContract, "Airports") + airport, out := importOne(t, trippinShapedContract, "Airport") got := attrByName(airport) if _, ok := got["Location_Address"]; ok { @@ -388,7 +388,7 @@ func TestCreateExternalEntities_DoesNotFlattenInheritedComplexProperties(t *test // The control: the SAME leaf name reached through a non-derived complex type // must still be imported. Without this the test passes against a build that // stopped flattening altogether. - person, _ := importOne(t, trippinShapedContract, "People") + person, _ := importOne(t, trippinShapedContract, "Person") if _, ok := attrByName(person)["HomeAddress_Address"]; !ok { t.Error("HomeAddress_Address is missing — Location is not derived, so it must flatten") } @@ -402,7 +402,7 @@ func TestCreateExternalEntities_DoesNotFlattenInheritedComplexProperties(t *test // // `!strings.HasPrefix(t, "Edm.")` is not enough — GeographyPoint passes it. func TestCreateExternalEntities_SkipsUnsupportedLeafTypes(t *testing.T) { - airport, out := importOne(t, trippinShapedContract, "Airports") + airport, out := importOne(t, trippinShapedContract, "Airport") if _, ok := attrByName(airport)["Location_Loc"]; ok { t.Error("Location_Loc was imported as an attribute — Edm.GeographyPoint is CE6622") } @@ -423,13 +423,13 @@ func TestCreateExternalEntities_SkipsUnsupportedLeafTypes(t *testing.T) { // Person (entity set People) HomeAddress_Address Filterable True // Employee (derived, no entity set) HomeAddress_Address Filterable False func TestCreateExternalEntities_FlattenedFilterabilityFollowsTheEntitySet(t *testing.T) { - person, _ := importOne(t, trippinShapedContract, "People") + person, _ := importOne(t, trippinShapedContract, "Person") top := attrByName(person)["HomeAddress_Address"] if top == nil { - t.Fatal("People.HomeAddress_Address missing") + t.Fatal("Person.HomeAddress_Address missing") } if !top.Filterable || !top.Sortable { - t.Errorf("People.HomeAddress_Address Filterable=%v Sortable=%v, want both true — "+ + t.Errorf("Person.HomeAddress_Address Filterable=%v Sortable=%v, want both true — "+ "the set is queryable and the contract restricts nothing (CE6630 in the other direction)", top.Filterable, top.Sortable) } diff --git a/mdl/executor/cmd_contract_entity_name_test.go b/mdl/executor/cmd_contract_entity_name_test.go new file mode 100644 index 000000000..7f41f9b86 --- /dev/null +++ b/mdl/executor/cmd_contract_entity_name_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mendixlabs/mxcli#1432: "Bulk external-entity creation names each entity after +// the OData entity set (`Parts`), while Studio Pro names it after the entity type +// (`Part`). A script that refers to the Studio Pro name then fails." +const partsContract = ` + + + + + + + + + + + + + + + + + + + +` + +type partsImport struct { + created map[string]*domainmodel.Entity + updated map[string]*domainmodel.Entity + assocs []*domainmodel.Association + out string +} + +// importParts runs CREATE OR MODIFY EXTERNAL ENTITIES FROM over partsContract +// into a module that already holds the given entities. +func importParts(t *testing.T, existing ...*domainmodel.Entity) partsImport { + t.Helper() + mod := mkModule("ModA") + svc := &model.ConsumedODataService{ + BaseElement: model.BaseElement{ID: nextID("cos")}, + ContainerID: mod.ID, + Name: "ClientA", + MetadataUrl: "http://localhost:18765/metadata.xml", + ODataVersion: "4.0", + Metadata: partsContract, + } + h := mkHierarchy(mod) + withContainer(h, svc.ContainerID, mod.ID) + + dm := &domainmodel.DomainModel{Entities: existing} + dm.ID = nextID("dm") + + r := partsImport{created: map[string]*domainmodel.Entity{}, updated: map[string]*domainmodel.Entity{}} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListConsumedODataServicesFunc: func() ([]*model.ConsumedODataService, error) { + return []*model.ConsumedODataService{svc}, nil + }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + CreateEntityFunc: func(_ model.ID, e *domainmodel.Entity) error { + r.created[e.Name] = e + dm.Entities = append(dm.Entities, e) + return nil + }, + UpdateEntityFunc: func(_ model.ID, e *domainmodel.Entity) error { + r.updated[e.Name] = e + return nil + }, + CreateAssociationFunc: func(_ model.ID, a *domainmodel.Association) error { + r.assocs = append(r.assocs, a) + dm.Associations = append(dm.Associations, a) + return nil + }, + ProjectVersionFunc: func() *types.ProjectVersion { + return &types.ProjectVersion{MajorVersion: 11, MinorVersion: 13, ProductVersion: "11.13.0"} + }, + } + + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + stmt := &ast.CreateExternalEntitiesStmt{ + ServiceRef: ast.QualifiedName{Module: "ModA", Name: "ClientA"}, + TargetModule: "ModA", + CreateOrModify: true, + } + assertNoError(t, createExternalEntities(ctx, stmt)) + r.out = buf.String() + return r +} + +func TestCreateExternalEntities_NamesEntityAfterTheEntityType(t *testing.T) { + r := importParts(t) + if _, ok := r.created["Parts"]; ok { + t.Errorf("entity named after the entity set (ModA.Parts); Studio Pro names it after the type (ModA.Part)") + } + part := r.created["Part"] + if part == nil { + t.Fatalf("ModA.Part not created; got %v", entityNamesOf(r.created)) + } + // The entity set is still what the runtime queries. + if part.RemoteEntitySet != "Parts" || part.RemoteEntityName != "Part" { + t.Errorf("RemoteEntitySet=%q RemoteEntityName=%q, want Parts / Part", part.RemoteEntitySet, part.RemoteEntityName) + } + if r.created["Supplier"] == nil { + t.Errorf("ModA.Supplier not created; got %v", entityNamesOf(r.created)) + } + // The navigation pass must find the type-named entities, or the association + // silently disappears. + if len(r.assocs) != 1 || r.assocs[0].Name != "Vendor" || + r.assocs[0].ParentID != part.ID || r.assocs[0].ChildID != r.created["Supplier"].ID { + t.Errorf("want one association Vendor from Part to Supplier, got %d", len(r.assocs)) + } +} + +// A module imported by an older mxcli holds the entity under its entity-set +// name. Re-importing must update that entity in place — a type-named twin +// beside it would map the same entity set twice, and renaming it would break +// every page and microflow written against the old name. +func TestCreateExternalEntities_ReimportKeepsAnEntitySetNamedEntity(t *testing.T) { + legacy := &domainmodel.Entity{ + Name: "Parts", + Source: "Rest$ODataRemoteEntitySource", + RemoteServiceName: "ModA.ClientA", + RemoteEntitySet: "Parts", + RemoteEntityName: "Part", + Persistable: true, + } + legacy.ID = nextID("ent") + + r := importParts(t, legacy) + if r.created["Part"] != nil { + t.Errorf("re-import created ModA.Part beside the existing ModA.Parts — the entity set is now mapped twice") + } + if r.updated["Parts"] == nil { + t.Errorf("the existing ModA.Parts was not updated; updated=%v created=%v", + entityNamesOf(r.updated), entityNamesOf(r.created)) + } + if !strings.Contains(r.out, "1 created, 1 updated") { + t.Errorf("want Supplier created and Parts updated:\n%s", r.out) + } + // The Vendor association must still resolve its FROM end to the legacy entity. + if len(r.assocs) != 1 || r.assocs[0].ParentID != legacy.ID { + t.Errorf("Vendor association does not start at the existing ModA.Parts") + } +} + +// The same entity set from a DIFFERENT service is not the same entity. +func TestCreateExternalEntities_ReimportIgnoresAnotherServicesEntitySet(t *testing.T) { + other := &domainmodel.Entity{ + Name: "Parts", + Source: "Rest$ODataRemoteEntitySource", + RemoteServiceName: "ModB.OtherClient", + RemoteEntitySet: "Parts", + Persistable: true, + } + other.ID = nextID("ent") + + r := importParts(t, other) + if r.created["Part"] == nil { + t.Errorf("ModA.Part not created; the other service's Parts was taken for it") + } + if r.updated["Parts"] != nil { + t.Errorf("another service's entity was overwritten") + } +} diff --git a/mdl/executor/cmd_contract_key_updatable_test.go b/mdl/executor/cmd_contract_key_updatable_test.go index 81bf050c9..9d2eb4e68 100644 --- a/mdl/executor/cmd_contract_key_updatable_test.go +++ b/mdl/executor/cmd_contract_key_updatable_test.go @@ -204,12 +204,12 @@ const derivedTypeMetadata = ` func TestCreateExternalEntities_DerivedTypeKeyStaysUpdatable(t *testing.T) { all, _ := importComplexTypeContract(t, derivedTypeMetadata) - top := all["People"] + top := all["Person"] if top == nil { - t.Fatalf("top-level entity People not created; got %v", entityNamesOf(all)) + t.Fatalf("top-level entity Person not created; got %v", entityNamesOf(all)) } if k := attrByName(top)["UserName"]; k == nil { - t.Fatal("People.UserName missing") + t.Fatal("Person.UserName missing") } else if k.Updatable { t.Error("top-level key is Updatable=true — CE6630 " + `"'UserName' is marked Updatable=False in the OData service, but True in the app."`) diff --git a/mdl/executor/cmd_contract_test.go b/mdl/executor/cmd_contract_test.go index 2a325c626..576c0e2ce 100644 --- a/mdl/executor/cmd_contract_test.go +++ b/mdl/executor/cmd_contract_test.go @@ -103,7 +103,7 @@ func TestCreateNavigationAssociations_NoDuplicateOnReimport(t *testing.T) { Schemas: []*types.EdmSchema{{Namespace: "NS", EntityTypes: []*types.EdmEntityType{person}}}, EntitySets: []*types.EdmEntitySet{{Name: "People", EntityType: "NS.Person"}}, } - people := &domainmodel.Entity{Name: "People", Persistable: true} + people := &domainmodel.Entity{Name: "Person", Persistable: true} people.ID = model.ID("ent-people") dm := &domainmodel.DomainModel{Entities: []*domainmodel.Entity{people}} dm.ID = model.ID("dm-1") From 8560ac7e7c016cc09d2e4a4a76688be33069f1b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:30:59 +0000 Subject: [PATCH 21/22] fix: importing partnered OData navigation properties no longer fails CE6618 createNavigationAssociations made one association per navigation property, so both ends of a bidirectional (Partner) relationship became separate associations and mxbuild rejected each with CE6618 "Navigation '...' is not available in the OData service". Measured on 11.12.1, Partner is the line, not multiplicity: unpartnered collections build clean. A partnered one-to-many pair is now ONE Reference from the single-valued end naming both navigation properties (BothDirections) - 0 errors. Partnered many-to-many and one-to-one pairs build in no form (one association, two, or Owner Both), so they are skipped and reported. A re-import names associations an earlier import made the old way as runnable `drop association` lines; drop + re-import builds clean. Repro: base 6x CE6618, fixed 0 errors. Fixes ako/mxcli#1289 (mendixlabs/mxcli#1231) asks 2 and the root cause; ask 1 (DROP ASSOCIATION) already existed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnpNHdpFqu1sPL7yNAQgTh --- ...-17-odata-partnered-navigation-ce6618.json | 1 + docs-site/src/reference/odata/README.md | 17 ++ ...1231-odata-partnered-navigation-ce6618.mdl | 46 +++++ .../partner-navigation-metadata.xml | 43 +++++ mdl/executor/cmd_contract.go | 138 ++++++++++++++ mdl/executor/cmd_contract_partner_nav_test.go | 173 ++++++++++++++++++ 6 files changed, 418 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-17-odata-partnered-navigation-ce6618.json create mode 100644 mdl-examples/bug-tests/1231-odata-partnered-navigation-ce6618.mdl create mode 100644 mdl-examples/odata-local-metadata/partner-navigation-metadata.xml create mode 100644 mdl/executor/cmd_contract_partner_nav_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-odata-partnered-navigation-ce6618.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-odata-partnered-navigation-ce6618.json new file mode 100644 index 000000000..9ac129373 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-odata-partnered-navigation-ce6618.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`create external entities from` an OData service whose navigation properties carry a `Partner` builds with CE6618 \"Navigation 'Tags' from external entity 'Buffer' to 'Tag' is not available in the OData service\" on every partnered end; check and lint are silent. Reported as \"*-to-* navigation properties auto-create ReferenceSet associations\"", "cause": "`createNavigationAssociations` created one association per navigation property, so both ends of a bidirectional (Partner) relationship became separate associations", "file": "`mdl/executor/cmd_contract.go` (`createNavigationAssociations`, new `partnerNavigation`; `indexExistingAssociations` now indexes the child navigation too)", "insight": "**The report's framing (multiplicity, *-to-*) was the wrong variable; Partner is the right one.** One contract with four shapes settled it in a single mx check run on 11.12.1: an unpartnered collection (`Peers`, a ReferenceSet) built clean beside partnered ends that all failed — including partnered ONE-to-many, which the report never mentioned. Put the control shape in the same contract, or the multiplicity theory looks confirmed. Then try each candidate form against mxbuild rather than reasoning about Studio Pro: one-to-many as ONE association from the single-valued end with RemoteParent/RemoteChildNavigationProperty + Navigability2=BothDirections -> 0 errors; many-to-many and one-to-one -> CE6618 as one association, as two, and with Owner Both, so Mendix simply does not offer them and the only correct import is to skip and say so. CE6618 is a precise oracle (it names navigation, entities and association), which is what made a five-minute prototype loop possible. A fix that changes what an import creates also needs an upgrade path: modules imported before it still hold the failing associations, and the dedup (existingNav) skips their navs forever, so the re-import names them as runnable `drop association …;` lines; measured: drop + re-import -> 0 errors, and for one-to-many the paired association is then created. That also answers the triage's open question: DROP ASSOCIATION removes a Rest$ODataRemoteAssociationSource association cleanly. Base build 6x CE6618 on the repro, fixed 0. Tests `cmd_contract_partner_nav_test.go`; repro `mdl-examples/bug-tests/1231-odata-partnered-navigation-ce6618.mdl`", "refs": ["mendixlabs/mxcli#1231", "ako/mxcli#1289"], "ce": ["CE6618"]} diff --git a/docs-site/src/reference/odata/README.md b/docs-site/src/reference/odata/README.md index b33716c70..0cf757761 100644 --- a/docs-site/src/reference/odata/README.md +++ b/docs-site/src/reference/odata/README.md @@ -86,6 +86,23 @@ Two more consequences: neither. `CE6630` fires in both directions here, so this follows the contract rather than a fixed answer. +### Navigation properties become associations + +Each navigation property whose target is also imported becomes an association. +A navigation property with a `Partner` is one end of a bidirectional +relationship, and Mendix does not accept its ends as separate associations +(`CE6618` "Navigation '…' is not available in the OData service"): + +| Navigation | Association | +|------------|-------------| +| No `Partner` | One per navigation property — `Reference`, or `ReferenceSet` for a collection | +| One-to-many pair (`Lines` / `ParentBuffer`) | One `Reference` from the single-valued end, naming both navigation properties | +| Many-to-many or one-to-one pair | None. Mendix offers no such navigation; the import lists the pair as not imported | + +A re-import of a module that an earlier mxcli version imported the old way lists +the associations that fail with `CE6618` as `drop association …;` lines. Run them, +then run the import again. + ## Contract Browsing Statements Browse available assets from cached service contracts without network access. diff --git a/mdl-examples/bug-tests/1231-odata-partnered-navigation-ce6618.mdl b/mdl-examples/bug-tests/1231-odata-partnered-navigation-ce6618.mdl new file mode 100644 index 000000000..ab4d5e380 --- /dev/null +++ b/mdl-examples/bug-tests/1231-odata-partnered-navigation-ce6618.mdl @@ -0,0 +1,46 @@ +mdl 1; +-- Bug mendixlabs/mxcli#1231 (ako/mxcli#1289): importing OData entities with +-- *-to-* navigation properties creates associations that fail with CE6618. +-- +-- Symptom, verbatim: "mxcli creates ReferenceSet associations automatically (one +-- per *-to-* navigation property on the imported entity). … These associations +-- are invalid for external entities and mxbuild rejects them with CE6618." +-- +-- Measured on mxbuild 11.12.1, the line is the navigation's PARTNER, not its +-- multiplicity. An unpartnered collection (Peers) builds clean. Every partnered +-- end imported as its own association is CE6618 "Navigation 'Lines' from +-- external entity 'Buffer' to 'Line' is not available in the OData service": +-- +-- one-to-many (Lines/ParentBuffer) ONE association from Line, naming both +-- ends, BothDirections -> 0 errors +-- many-to-many (CapacityTypes/Buffers) no form builds: one association, two, +-- one-to-one (Twin/TwinBuffer) or Owner Both all give CE6618 +-- +-- So the import now pairs the one-to-many ends, skips the other two and says so, +-- and on re-import names (as `drop association …;` lines) any association an +-- earlier mxcli made the old way. Before the fix this script built with 6x +-- CE6618; after it, 0 errors. +-- +-- Run it (MetadataUrl resolves relative to the project directory): +-- +-- mkdir -p /path/to/app/mdl-examples/odata-local-metadata +-- cp mdl-examples/odata-local-metadata/partner-navigation-metadata.xml /path/to/app/mdl-examples/odata-local-metadata/ +-- mxcli exec mdl-examples/bug-tests/1231-odata-partnered-navigation-ce6618.mdl -p app.mpr +-- mxcli docker check -p app.mpr + +create module Issue1231; + +create constant Issue1231.SvcUrl + ( Type: string, DefaultValue: 'http://localhost:18765/' ); + +create consumed odata service Issue1231.Svc ( + ODataVersion: OData4, + MetadataUrl: './mdl-examples/odata-local-metadata/partner-navigation-metadata.xml', + ServiceUrl: '@Issue1231.SvcUrl' +); + +-- Expect: "Created 2 navigation associations" (ParentBuffer, Peers) and a report +-- naming the many-to-many and one-to-one pairs as not imported. +create external entities from Issue1231.Svc into Issue1231; + +list associations in Issue1231; diff --git a/mdl-examples/odata-local-metadata/partner-navigation-metadata.xml b/mdl-examples/odata-local-metadata/partner-navigation-metadata.xml new file mode 100644 index 000000000..ca71540ab --- /dev/null +++ b/mdl-examples/odata-local-metadata/partner-navigation-metadata.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdl/executor/cmd_contract.go b/mdl/executor/cmd_contract.go index 6834229d7..ab87412ec 100644 --- a/mdl/executor/cmd_contract.go +++ b/mdl/executor/cmd_contract.go @@ -1166,6 +1166,22 @@ func createNavigationAssociations( // association name (== nav-property name) is the fallback skip signal. existingAssocs, existingNav := indexExistingAssociations(dm) + // The association an earlier import made for a navigation property, keyed + // like existingNav, so a partnered end imported the old way can be named. + navAssoc := make(map[assocKey]*domainmodel.Association) + entityName := make(map[model.ID]string, len(dm.Entities)) + for _, ent := range dm.Entities { + entityName[ent.ID] = ent.Name + } + for _, a := range dm.Associations { + if p, ok := entityName[a.ParentID]; ok && a.RemoteParentNavigationProperty != "" { + navAssoc[assocKey{p, a.RemoteParentNavigationProperty}] = a + } + } + // Partnered navigations Mendix does not offer, and associations from an + // earlier import that map a partnered end the way that is CE6618. + var unavailable, stale []string + count := 0 for _, schema := range doc.Schemas { for _, et := range schema.EntityTypes { @@ -1204,6 +1220,55 @@ func createNavigationAssociations( continue } + // A navigation property with a Partner is one END of a + // bidirectional relationship, and Mendix does not treat the ends + // separately (mendixlabs/mxcli#1231). Measured on 11.12.1, with + // importing each end as its own association as the baseline — + // CE6618 "Navigation 'Lines' from external entity 'Buffer' to + // 'Line' is not available in the OData service" on every end: + // + // one-to-many ONE association, from the single-valued end, + // naming both properties 0 errors + // many-to-many no form builds — one association, two, or + // one-to-one Owner Both: CE6618 on both ends + // + // So a one-to-many pair is created once, by its single-valued end + // (it holds the reference, so it is the FROM entity), and the + // other two are not created at all and are reported instead. + // The unpartnered collection navigations beside them in the same + // contract build clean, which is what makes Partner the line. + partner := partnerNavigation(np, parentQN, typeByQualified[targetTypeName]) + if partner != nil { + partnerMany := strings.HasPrefix(partner.Type, "Collection(") + if isMany == partnerMany { + if prior := navAssoc[assocKey{parentEnt.Name, np.Name}]; prior != nil { + stale = append(stale, prior.Name) + } + // Once per pair: from the end that sorts first. + if parentQN+"/"+np.Name <= targetTypeName+"/"+partner.Name { + kind := "many-to-many" + if !isMany { + kind = "one-to-one" + } + unavailable = append(unavailable, fmt.Sprintf("%s.%s / %s.%s (%s)", + parentEnt.Name, np.Name, childEnt.Name, partner.Name, kind)) + } + continue + } + if isMany { + // The single-valued end creates the association; one + // an earlier import made from this end is CE6618. + if prior := navAssoc[assocKey{parentEnt.Name, np.Name}]; prior != nil { + stale = append(stale, prior.Name) + } + continue + } + if prior := navAssoc[assocKey{parentEnt.Name, np.Name}]; prior != nil && + prior.RemoteChildNavigationProperty == "" { + stale = append(stale, prior.Name) + } + } + // Re-import dedup: if this nav property already has an // association on the parent, skip it — otherwise a second import // creates a numerically-suffixed duplicate (Friends, Friends2, @@ -1263,6 +1328,28 @@ func createNavigationAssociations( CreatableFromParent: creatable, UpdatableFromParent: updatable, } + if partner != nil { + // The child end's capabilities come from the TARGET's entity + // set, exactly as the parent end's come from the source's. + creatableFromChild := true + updatableFromChild := true + if es, ok := esByType[targetTypeName]; ok { + creatableFromChild = es.Insertable != nil && *es.Insertable + updatableFromChild = es.Updatable != nil && *es.Updatable + } + if r, ok := restrictionsByType[targetTypeName]; ok { + if r.nonInsertable[partner.Name] { + creatableFromChild = false + } + if r.nonUpdatable[partner.Name] { + updatableFromChild = false + } + } + assoc.RemoteChildNavigationProperty = partner.Name + assoc.Navigability2 = "BothDirections" + assoc.CreatableFromChild = creatableFromChild + assoc.UpdatableFromChild = updatableFromChild + } assoc.ID = model.ID(types.GenerateID()) if err := ctx.Backend.CreateAssociation(dm.ID, assoc); err != nil { @@ -1271,13 +1358,59 @@ func createNavigationAssociations( } existingAssocs[assocKey{parentEnt.Name, assocName}] = true existingNav[assocKey{parentEnt.Name, np.Name}] = true + if partner != nil { + existingNav[assocKey{childEnt.Name, partner.Name}] = true + } count++ } } } + + if len(unavailable) > 0 { + sort.Strings(unavailable) + fmt.Fprintf(ctx.Output, "\n %d partnered navigation pair(s) not imported — Mendix does not offer a\n", len(unavailable)) + fmt.Fprintf(ctx.Output, " bidirectional many-to-many or one-to-one navigation, and an association\n") + fmt.Fprintf(ctx.Output, " for either end fails the build with CE6618:\n") + for _, u := range unavailable { + fmt.Fprintf(ctx.Output, " %s\n", u) + } + } + if len(stale) > 0 { + sort.Strings(stale) + fmt.Fprintf(ctx.Output, "\n %d association(s) from an earlier import map one end of a partnered\n", len(stale)) + fmt.Fprintf(ctx.Output, " navigation separately, which fails the build with CE6618. Drop them and\n") + fmt.Fprintf(ctx.Output, " re-run this import:\n") + prefix := "" + if mod := moduleNameOfDomainModel(ctx, dm); mod != "" { + prefix = mod + "." + } + for _, name := range stale { + fmt.Fprintf(ctx.Output, " drop association %s%s;\n", prefix, name) + } + } return count } +// partnerNavigation returns the navigation property on the target type that np +// names as its Partner, provided it points back at np's own type — or nil when +// np is one-directional. A Partner that does not lead back is malformed +// metadata, and treating it as one-directional is what Mendix can still map. +func partnerNavigation(np *types.EdmNavigationProperty, ownerQN string, target *types.EdmEntityType) *types.EdmNavigationProperty { + if np.Partner == "" || target == nil { + return nil + } + for _, other := range target.NavigationProperties { + if other.Name != np.Partner || other.ContainsTarget { + continue + } + back := strings.TrimSuffix(strings.TrimPrefix(other.Type, "Collection("), ")") + if back == ownerQN { + return other + } + } + return nil +} + // indexExistingAssociations builds the two lookup tables the re-import dedup // needs, both keyed by (parent entity name, name): // @@ -1309,6 +1442,11 @@ func indexExistingAssociations(dm *domainmodel.DomainModel) (byName, byNav map[a if a.RemoteParentNavigationProperty != "" { byNav[assocKey{p, a.RemoteParentNavigationProperty}] = true } + // A partnered pair is one association; its other end is reached from + // the child entity under the child navigation property. + if c, ok := parentName[a.ChildID]; ok && a.RemoteChildNavigationProperty != "" { + byNav[assocKey{c, a.RemoteChildNavigationProperty}] = true + } } return byName, byNav } diff --git a/mdl/executor/cmd_contract_partner_nav_test.go b/mdl/executor/cmd_contract_partner_nav_test.go new file mode 100644 index 000000000..aca33b78a --- /dev/null +++ b/mdl/executor/cmd_contract_partner_nav_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mendixlabs/mxcli#1231: importing an OData entity with *-to-* navigation +// properties "auto-creates ReferenceSet associations … These associations are +// invalid for external entities and cause CE6618 at mxbuild time", and "check +// and lint are both silent". +// +// Measured on 11.12.1, the line is the navigation's Partner, not its +// multiplicity: an unpartnered collection builds clean, and every PARTNERED end +// imported as its own association is CE6618. A one-to-many pair builds as ONE +// association naming both ends; a many-to-many or one-to-one pair builds in no +// form at all. + +func navType(name string, navs ...*types.EdmNavigationProperty) *types.EdmEntityType { + return &types.EdmEntityType{Name: name, KeyProperties: []string{"Id"}, NavigationProperties: navs} +} + +func nav(name, typ, partner string) *types.EdmNavigationProperty { + return &types.EdmNavigationProperty{Name: name, Type: typ, Partner: partner} +} + +type navRun struct { + dm *domainmodel.DomainModel + ents map[string]*domainmodel.Entity + created []*domainmodel.Association + doc *types.EdmxDocument + byQN map[string]*types.EdmEntityType + esMap map[string]string +} + +// newNavRun builds a domain model holding one persistable external entity per +// type, named after the type, each backed by its own entity set. +func newNavRun(ets ...*types.EdmEntityType) *navRun { + r := &navRun{ents: map[string]*domainmodel.Entity{}, byQN: map[string]*types.EdmEntityType{}, esMap: map[string]string{}} + r.dm = &domainmodel.DomainModel{} + r.dm.ID = model.ID("dm-nav") + r.doc = &types.EdmxDocument{Schemas: []*types.EdmSchema{{Namespace: "Demo", EntityTypes: ets}}} + for _, et := range ets { + qn := "Demo." + et.Name + e := &domainmodel.Entity{Name: et.Name, Persistable: true} + e.ID = model.ID("ent-" + et.Name) + r.dm.Entities = append(r.dm.Entities, e) + r.ents[et.Name] = e + r.byQN[qn] = et + r.esMap[qn] = et.Name + "s" + r.doc.EntitySets = append(r.doc.EntitySets, &types.EdmEntitySet{Name: et.Name + "s", EntityType: qn}) + } + return r +} + +func (r *navRun) run(t *testing.T) (int, string) { + t.Helper() + mb := &mock.MockBackend{ + CreateAssociationFunc: func(_ model.ID, a *domainmodel.Association) error { + r.created = append(r.created, a) + r.dm.Associations = append(r.dm.Associations, a) + return nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + n := createNavigationAssociations(ctx, r.dm, r.doc, r.byQN, r.esMap, "Ext.Svc") + return n, buf.String() +} + +func TestPartneredOneToManyIsOneAssociation(t *testing.T) { + r := newNavRun( + navType("Buffer", nav("Lines", "Collection(Demo.Line)", "Buffer")), + navType("Line", nav("Buffer", "Demo.Buffer", "Lines")), + ) + if n, out := r.run(t); n != 1 { + t.Fatalf("created %d associations, want 1 — each end as its own association is CE6618 on both:\n%s", n, out) + } + a := r.created[0] + // The single-valued end holds the reference, so it is the FROM entity. + if a.ParentID != r.ents["Line"].ID || a.ChildID != r.ents["Buffer"].ID { + t.Errorf("association runs %s -> %s, want Line -> Buffer", a.ParentID, a.ChildID) + } + if a.Type != domainmodel.AssociationTypeReference { + t.Errorf("Type = %s, want Reference", a.Type) + } + if a.RemoteParentNavigationProperty != "Buffer" || a.RemoteChildNavigationProperty != "Lines" { + t.Errorf("navigation properties %q / %q, want Buffer / Lines", + a.RemoteParentNavigationProperty, a.RemoteChildNavigationProperty) + } + if a.Navigability2 != "BothDirections" { + t.Errorf("Navigability2 = %q, want BothDirections", a.Navigability2) + } + + // A re-import must recognise the pair from either end. + if n, out := r.run(t); n != 0 { + t.Errorf("re-import created %d more associations, want 0:\n%s", n, out) + } +} + +func TestPartneredManyToManyAndOneToOneAreNotImported(t *testing.T) { + r := newNavRun( + navType("Buffer", + nav("CapacityTypes", "Collection(Demo.CapacityType)", "Buffers"), + nav("Twin", "Demo.Twin", "TwinBuffer"), + // CONTROL: an unpartnered collection builds clean and must stay. + nav("Peers", "Collection(Demo.Buffer)", "")), + navType("CapacityType", nav("Buffers", "Collection(Demo.Buffer)", "CapacityTypes")), + navType("Twin", nav("TwinBuffer", "Demo.Buffer", "Twin")), + ) + n, out := r.run(t) + if n != 1 || r.created[0].Name != "Peers" { + names := []string{} + for _, a := range r.created { + names = append(names, a.Name) + } + t.Fatalf("created %v, want only Peers — a partnered many-to-many or one-to-one end is CE6618", names) + } + if r.created[0].Type != domainmodel.AssociationTypeReferenceSet || r.created[0].RemoteChildNavigationProperty != "" { + t.Errorf("the unpartnered Peers association changed shape") + } + for _, want := range []string{ + "Buffer.CapacityTypes / CapacityType.Buffers (many-to-many)", + "Buffer.Twin / Twin.TwinBuffer (one-to-one)", + "CE6618", + } { + if !strings.Contains(out, want) { + t.Errorf("output does not report %q:\n%s", want, out) + } + } + // Once per pair, not once per end. + if c := strings.Count(out, "CapacityTypes / "); c != 1 { + t.Errorf("the many-to-many pair is reported %d times, want 1:\n%s", c, out) + } +} + +// A module imported before this fix already holds the CE6618 associations. +// They are not ours to delete silently, but the import must name them. +func TestReimportNamesAssociationsThatMapAPartneredEndSeparately(t *testing.T) { + r := newNavRun( + navType("Buffer", + nav("CapacityTypes", "Collection(Demo.CapacityType)", "Buffers"), + nav("Lines", "Collection(Demo.Line)", "Buffer")), + navType("CapacityType", nav("Buffers", "Collection(Demo.Buffer)", "CapacityTypes")), + navType("Line", nav("Buffer", "Demo.Buffer", "Lines")), + ) + old := func(name, from, to, navName string) { + a := &domainmodel.Association{Name: name, ParentID: r.ents[from].ID, ChildID: r.ents[to].ID, + Source: "Rest$ODataRemoteAssociationSource", RemoteParentNavigationProperty: navName} + a.ID = model.ID("old-" + name) + r.dm.Associations = append(r.dm.Associations, a) + } + old("CapacityTypes", "Buffer", "CapacityType", "CapacityTypes") + old("Buffers", "CapacityType", "Buffer", "Buffers") + old("Lines", "Buffer", "Line", "Lines") + old("Buffer_2", "Line", "Buffer", "Buffer") + + n, out := r.run(t) + if n != 0 { + t.Errorf("created %d associations beside the old ones, want 0", n) + } + for _, name := range []string{"CapacityTypes", "Buffers", "Lines", "Buffer_2"} { + if !strings.Contains(out, "drop association "+name+";") { + t.Errorf("output does not name %s for dropping:\n%s", name, out) + } + } +} From 2e66ad1d5f1b8bfa9067c72842126eb4bdbeae2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:40:39 +0000 Subject: [PATCH 22/22] fix: copying an Atlas layout keeps its header bar and native parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe layout -> create layout dropped two things from Atlas layouts: - Phone_/Tablet_ layouts: their Forms$Header (printed NOT re-executable), with the HeaderLeft / HeaderRight placeholders pages bind to (CE1613) and, on Phone_Sidebar / Tablet_Sidebar, the sidebar toggle. With region ToggleMode now copied, those two failed mx check with CE0611 "A sidebar toggle button is required if a region can toggle". - Native layouts: the header placeholder, ShowBottomBar and the sidebar, dropped with no note; the content was written with 4 of the 8 keys Studio Pro stores. `layoutheader name { region left { … } region right { … } }` writes the header. A native layout takes `showbottombar: true`, `region header { placeholder X }` and `region sidebar { … }`. Measured on a new 11.13.0 app: all 22 Atlas_Core layouts copied, with pages bound to the copied header and sidebar placeholders, build with 0 errors; main's copy of NativePhone_SideMenu fails CE1613 twice. The CE0535 earlier blamed on the copy is the PedApp fixture's own, not a copy defect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wx8dgxQm9qE8ZsMxwRweXj --- ...6-10-17-sidebar-toggle-not-authorable.json | 2 +- ...-layout-drops-header-and-native-parts.json | 1 + .claude/skills/mendix/write-layouts/SKILL.md | 21 ++- CHANGELOG.md | 3 +- cmd/mxcli/lsp_completions_gen.go | 1 + cmd/mxcli/syntax/features_page.go | 12 +- .../syntax/widget_keywords_drift_test.go | 1 + docs-site/src/reference/page/create-layout.md | 21 +++ .../1220-layout-header-and-native-parts.mdl | 60 ++++++++ mdl/backend/modelsdk/layout_write.go | 25 +++ mdl/backend/modelsdk/widget_write.go | 37 +++++ mdl/executor/cmd_pages_builder_v3.go | 44 ++++++ mdl/executor/cmd_pages_describe.go | 52 +++++++ mdl/executor/cmd_pages_describe_output.go | 20 +++ mdl/executor/cmd_pages_describe_parse.go | 16 ++ mdl/executor/cmd_pages_layout_v3.go | 69 ++++++++- .../design_property_keyword_keys_test.go | 1 + .../sidebartoggle_copy_layout_pedapp_test.go | 143 ++++++++++++++++++ mdl/executor/theme_reader.go | 2 + mdl/executor/validate_page_context.go | 2 +- mdl/executor/widget_keywords.go | 3 +- mdl/grammar/MDLLexer.g4 | 1 + mdl/grammar/domains/MDLPage.g4 | 1 + mdl/grammar/domains/MDLSettings.g4 | 2 +- sdk/pages/pages.go | 9 ++ sdk/pages/pages_widgets_advanced.go | 13 ++ 26 files changed, 549 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-18-copy-layout-drops-header-and-native-parts.json create mode 100644 mdl-examples/bug-tests/1220-layout-header-and-native-parts.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json index e2eed982b..2ffdef1af 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-sidebar-toggle-not-authorable.json @@ -1 +1 @@ -{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`describe layout Atlas_Core.Atlas_Default` printed `-- Forms$SidebarToggleButton (sidebarToggle3) -- NOT re-executable: mxcli cannot author this widget`, so a copied layout lost Atlas's responsive sidebar toggle", "cause": "No MDL keyword or writer for Forms$SidebarToggleButton; modelsdk/gen bound its caption under the SDK name `Caption` (the key-audit ledger listed it) where Studio Pro stores `CaptionTemplate`; and the scroll container region's `ToggleMode` was neither described nor written, so every region came out `None`", "file": "`mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildSidebarToggleV3`, `buildButtonFaceV3`), `mdl/backend/modelsdk/widget_write.go`, `mdl/executor/cmd_pages_describe_output.go`, `modelsdk/gen/pages/types.go` (initSidebarToggleButton)", "insight": "Writing the widget was not enough: the first real `mxcli docker check` of the copy reported CE0628 \"For the sidebar toggle button to work, the outermost widget on the layout must be a scroll container with a region that can be toggled\" because the region's ToggleMode (ShrinkContentInitiallyClosed on Atlas's sidebar) had been dropped all along — invisible until a toggle existed. A widget's correctness can live in a SIBLING's property; run mxbuild on a describe→exec copy, diffing its errors against an untouched copy of the fixture (PedApp has no theme, so ~1000 CE6083s are baseline noise). Compare the written key set to the Studio Pro document (test `TestCopyAtlasDefaultKeepsTheSidebarToggle`) — reverting the gen key override fails it with `Caption` vs `CaptionTemplate`. A widget keyword parsed as a bare IDENTIFIER is TypeIsGeneric and refused as MDL-WIDGET25, so a builder-owned widget needs a lexer token. Separate, pre-existing on main: the copy also reports CE0535 \"weights of the columns in a row must add up to 12, but currently they add up to -3\" for a layout grid row with AutoFill/AutoFit columns", "refs": ["#1285", "mendixlabs/mxcli#1220"]} +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`describe layout Atlas_Core.Atlas_Default` printed `-- Forms$SidebarToggleButton (sidebarToggle3) -- NOT re-executable: mxcli cannot author this widget`, so a copied layout lost Atlas's responsive sidebar toggle", "cause": "No MDL keyword or writer for Forms$SidebarToggleButton; modelsdk/gen bound its caption under the SDK name `Caption` (the key-audit ledger listed it) where Studio Pro stores `CaptionTemplate`; and the scroll container region's `ToggleMode` was neither described nor written, so every region came out `None`", "file": "`mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildSidebarToggleV3`, `buildButtonFaceV3`), `mdl/backend/modelsdk/widget_write.go`, `mdl/executor/cmd_pages_describe_output.go`, `modelsdk/gen/pages/types.go` (initSidebarToggleButton)", "insight": "Writing the widget was not enough: the first real `mxcli docker check` of the copy reported CE0628 \"For the sidebar toggle button to work, the outermost widget on the layout must be a scroll container with a region that can be toggled\" because the region's ToggleMode (ShrinkContentInitiallyClosed on Atlas's sidebar) had been dropped all along — invisible until a toggle existed. A widget's correctness can live in a SIBLING's property; run mxbuild on a describe→exec copy, diffing its errors against an untouched copy of the fixture (PedApp has no theme, so ~1000 CE6083s are baseline noise). Compare the written key set to the Studio Pro document (test `TestCopyAtlasDefaultKeepsTheSidebarToggle`) — reverting the gen key override fails it with `Caption` vs `CaptionTemplate`. A widget keyword parsed as a bare IDENTIFIER is TypeIsGeneric and refused as MDL-WIDGET25, so a builder-owned widget needs a lexer token.", "refs": ["#1285", "mendixlabs/mxcli#1220"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-18-copy-layout-drops-header-and-native-parts.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-18-copy-layout-drops-header-and-native-parts.json new file mode 100644 index 000000000..3bccebd7e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-18-copy-layout-drops-header-and-native-parts.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-18", "symptom": "describe layout → create layout copies of Atlas layouts: Phone_/Tablet_ copies print `-- Forms$Header (header1) -- NOT re-executable` and lose HeaderLeft/HeaderRight (pages bound to them: CE1613); Phone_Sidebar/Tablet_Sidebar copies fail mx check CE0611 \"A sidebar toggle button is required if a region can toggle\"; NativePhone_* copies silently lose the Header placeholder, ShowBottomBar and the sidebar (pages: CE1613 \"The selected placeholder '…Header' no longer exists\")", "cause": "No MDL for Forms$Header (LeftWidgets/RightWidgets), so the header and the toggle inside it were dropped while the region's ToggleMode (round-tripped since #1220) survived — the CE0611 was that fix exposing the drop. Native layouts: layoutContentToGen wrote 4 of the 8 keys of Forms$NativeLayoutContent and describe never read RightHeaderPlaceholder / ShowBottomBar / Sidebar / SidebarWidgets, with no NOT re-executable note", "file": "`mdl/executor/cmd_pages_builder_v3.go` (`buildLayoutHeaderV3`), `mdl/executor/cmd_pages_layout_v3.go` (`applyNativeLayoutParts`), `mdl/executor/cmd_pages_describe.go` (`outputNativeLayoutPartsMDL`), `mdl/backend/modelsdk/layout_write.go`", "insight": "Test a copy path against EVERY document of the kind, not the reported one: `TestCopyEveryAtlasLayoutKeepsEveryWidget` copies all 22 Atlas_Core layouts and compares each copy's widget $Types+key sets and its Content wrapper to the original — that found the native drop, which no NOT re-executable comment ever announced. Do the mx check on a NEW app (`mxcli new … --version 11.13.0`), not testdata/pedapp: PedApp ships no theme and reports ~1000 CE6083 and 26 CE0535 on its own untouched layouts, and a CE0535 on a copy was first misread as a copy defect — it is the fixture's (a new app copying all 22 is 0 errors). Fixing one property (ToggleMode) can turn a silent drop next to it into a build error; rerun the whole set after each. Control: main's binary, copying NativePhone_SideMenu plus a page bound to its placeholders → 2× CE1613; this branch → 0 errors", "refs": ["#1285", "mendixlabs/mxcli#1220"]} diff --git a/.claude/skills/mendix/write-layouts/SKILL.md b/.claude/skills/mendix/write-layouts/SKILL.md index ed72689fc..d6f2c2066 100644 --- a/.claude/skills/mendix/write-layouts/SKILL.md +++ b/.claude/skills/mendix/write-layouts/SKILL.md @@ -15,10 +15,10 @@ description: "CREATE LAYOUT syntax — the frame a page is built on: scroll-cont | 195-234 | The four things only a layout has | | 235-246 | Layout type, and why there is no `native:` flag | | 247-268 | The layout class is load-bearing | -| 269-317 | Gotchas | -| 318-330 | Validate | -| 331-343 | What `mxcli new` scaffolds | -| 344-346 | Layouts (SHOW/DESCRIBE/CREATE [OR REPLACE] LAYOUT) | +| 269-328 | Gotchas | +| 329-341 | Validate | +| 342-354 | What `mxcli new` scaffolds | +| 355-357 | Layouts (SHOW/DESCRIBE/CREATE [OR REPLACE] LAYOUT) | # CREATE LAYOUT — the frame a page is built on @@ -307,7 +307,18 @@ section. with no `Action`: it opens and closes the layout's sidebar, which is how Atlas's responsive layouts navigate on phones and tablets. A copy of `Atlas_Core.Atlas_Default` keeps it. -- **The menu bar's logo and Atlas's `Forms$Header` are not authorable.** +- **A phone/tablet header bar is `layoutheader`** — Atlas's Phone_ and Tablet_ + layouts keep their `HeaderLeft` / `HeaderRight` placeholders (and, on the + Sidebar ones, the sidebar toggle) in a `Forms$Header`: + `layoutheader header1 { region left { … } region right { placeholder HeaderRight } }`. + Before it was authorable, a copy dropped the header, so pages bound to those + placeholders failed CE1613 and a Sidebar copy failed CE0611 ("A sidebar + toggle button is required if a region can toggle"). +- **A native layout's header placeholder, bottom bar and sidebar** are + `region header { placeholder Header }`, `showbottombar: true` in the header, + and `region sidebar { … }` (present = the layout has a sidebar). A copy used + to drop all three without a word. +- **The menu bar's logo is not authorable.** - **Authoring is modelsdk-only.** The legacy writer cannot produce the `Content` wrapper the widget tree hangs off; it refuses rather than writing a layout with no tree. diff --git a/CHANGELOG.md b/CHANGELOG.md index ba3ee421f..ec1630394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **`sidebartoggle` — Atlas's responsive sidebar toggle (`Forms$SidebarToggleButton`) in MDL** (mendixlabs/mxcli#1220) — copying `Atlas_Core.Atlas_Default` with `describe layout` → `create layout` dropped the button that opens the sidebar on phones and tablets: `describe` printed it as a NOT re-executable comment, and `exec` gave no warning. `sidebartoggle sidebarToggle3 (Tooltip: 'Toggle Menu', ButtonStyle: Primary, Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', Class: 'toggle-btn')` takes an action button's caption, tooltip, style and icon, and `RenderMode: Link`; it has no action. The written widget has the keys of Atlas's own, including its caption under `CaptionTemplate` rather than the `Caption` key the vendored metamodel bound. +- **`layoutheader` and native layout parts — every Atlas layout copies whole** — `describe layout` → `create layout` of Atlas's Phone_ and Tablet_ layouts dropped their header bar (`Forms$Header`, printed as NOT re-executable), so pages bound to its `HeaderLeft` / `HeaderRight` placeholders failed CE1613 and a copy of Phone_Sidebar or Tablet_Sidebar failed CE0611 "A sidebar toggle button is required if a region can toggle". `layoutheader header1 { region left { … } region right { … } }` writes it. A native layout's copy silently lost its header placeholder, bottom bar and sidebar — pages bound to `Header` or a sidebar placeholder then failed CE1613; they are `region header { placeholder Header }`, `showbottombar: true` and `region sidebar { … }`, and the native content is written with the eight keys Studio Pro stores. Measured on a new 11.13.0 app: all 22 Atlas_Core layouts copied, with pages bound to the copied header and sidebar placeholders, build with 0 errors. +- **`sidebartoggle` — Atlas's responsive sidebar toggle (`Forms$SidebarToggleButton`) in MDL** (mendixlabs/mxcli#1220) — copying `Atlas_Core.Atlas_Default` with `describe layout` → `create layout` dropped the button that opens the sidebar on phones and tablets: `describe` printed it as a NOT re-executable comment, and `exec` gave no warning. `sidebartoggle sidebarToggle3 (Tooltip: 'Toggle Menu', ButtonStyle: Primary, Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', Class: 'toggle-btn')` takes an action button's caption, tooltip, style and icon, and `RenderMode: Link`; it has no action. The written widget has the keys of Atlas's own, including its caption under `CaptionTemplate` rather than the `Caption` key the vendored metamodel bound. A scroll container region's `ToggleMode` (`ToggleMode: 'ShrinkContentInitiallyClosed'`), which the toggle opens, is described and written too — without it the copy failed CE0628. ### Fixed diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index d9cad433d..baa6f35a1 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -191,6 +191,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "MENUBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SIMPLEMENUBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SIDEBARTOGGLE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, + {Label: "LAYOUTHEADER", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "NAVIGATIONTREE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SNIPPETCALL", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "LAYOUTGRID", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 28ca188ab..33233592b 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -478,7 +478,8 @@ LIST IMPACT OF htmlelement; "layout", "layouts", "create layout", "scrollcontainer", "region", "placeholder", "navigationtree", "topbar", "sidebar", "frame", "menubar", "simplemenubar", "bottom bar", "phone layout", "menu document", - "sidebartoggle", "sidebar toggle", "toggle button", + "sidebartoggle", "sidebar toggle", "toggle button", "layoutheader", "header bar", + "native layout", "showbottombar", "native sidebar", }, Syntax: "CREATE [OR MODIFY] LAYOUT Module.Name (\n" + " layouttype: 'Responsive' | 'Phone' | 'Tablet' | 'ModalPopup' -- web\n" + @@ -498,11 +499,20 @@ LIST IMPACT OF htmlelement; " SIDEBARTOGGLE name ([Caption: '…', Tooltip: '…', ButtonStyle: Primary,\n" + " Icon: 'Mod.Collection.icon', RenderMode: Button | Link])\n" + " -- the button that opens and closes the sidebar on phones and tablets\n" + + " LAYOUTHEADER name { REGION left { … } REGION right { … } }\n" + + " -- a phone/tablet layout's header bar (Atlas: HeaderLeft / HeaderRight)\n" + " -- Every menu widget renders EITHER a navigation profile (Profile: '…')\n" + " -- OR a menu document (Menu: Module.Menu, see CREATE MENU) — not both.\n" + " PLACEHOLDER Main\n" + " }\n" + " }\n" + + "}\n" + + "-- A native layout (Default | Popup) has no scroll container; beside its\n" + + "-- content it keeps a header placeholder, a bottom bar and a sidebar:\n" + + "CREATE LAYOUT Module.Name (layouttype: 'Default' [, showbottombar: true]) {\n" + + " PLACEHOLDER Main\n" + + " [REGION header { PLACEHOLDER Header }] -- the header's right side\n" + + " [REGION sidebar { … }] -- present = the layout has a sidebar\n" + "}", Example: "mdl 1;\n" + "-- Mendix's own guidance is not to edit the supplied Atlas layouts:\n" + diff --git a/cmd/mxcli/syntax/widget_keywords_drift_test.go b/cmd/mxcli/syntax/widget_keywords_drift_test.go index 3b80f256f..ddbf8e0d9 100644 --- a/cmd/mxcli/syntax/widget_keywords_drift_test.go +++ b/cmd/mxcli/syntax/widget_keywords_drift_test.go @@ -97,6 +97,7 @@ var documentedElsewhere = map[string]string{ "menubar": "layout", "simplemenubar": "layout", "sidebartoggle": "layout", + "layoutheader": "layout", "placeholder": "layout", // Object-list container keywords for pluggable widgets: each is the singular // form of one widget's own list property (Accordion groups → GROUP), routed diff --git a/docs-site/src/reference/page/create-layout.md b/docs-site/src/reference/page/create-layout.md index 7923a90ac..e08e74e8d 100644 --- a/docs-site/src/reference/page/create-layout.md +++ b/docs-site/src/reference/page/create-layout.md @@ -74,6 +74,27 @@ sidebartoggle sidebarToggle3 ( It takes an action button's `Caption`, `Tooltip`, `ButtonStyle` and `Icon`, and `RenderMode: Link` to render as a link; it has no `Action`. +The phone and tablet layouts keep their header bar in a `layoutheader`, whose +two sides are spelled like a scroll container's regions: + +```sql +layoutheader header1 { + region left { sidebartoggle sidebarToggle2 (RenderMode: Link, Icon: 'Atlas_Core.Atlas_Filled.navigation-menu') } + region right { placeholder HeaderRight } +} +``` + +A native layout keeps three things beside its content tree: the placeholder on +the header's right, whether the bottom bar shows, and the sidebar. + +```sql +create layout MyModule.Native_SideMenu (layouttype: 'Default', showbottombar: true) { + placeholder Main + region header { placeholder Header } + region sidebar { placeholder Placeholder1 } +}; +``` + ## Parameters `module.Name` diff --git a/mdl-examples/bug-tests/1220-layout-header-and-native-parts.mdl b/mdl-examples/bug-tests/1220-layout-header-and-native-parts.mdl new file mode 100644 index 000000000..b785ca9d0 --- /dev/null +++ b/mdl-examples/bug-tests/1220-layout-header-and-native-parts.mdl @@ -0,0 +1,60 @@ +mdl 1; +-- ============================================================================ +-- Copying Atlas layouts with `describe layout` → `create layout` (follow-up to +-- #1220). Three things a copy lost: +-- +-- 1. Phone_ / Tablet_ layouts: their Forms$Header, printed as +-- `-- Forms$Header (header1) -- NOT re-executable`, with the HeaderLeft / +-- HeaderRight placeholders (pages bound to them: CE1613) and, on the +-- Sidebar ones, the sidebar toggle (copy: CE0611 "A sidebar toggle button +-- is required if a region can toggle"). +-- 2. Native layouts: the header placeholder, ShowBottomBar and the sidebar, +-- dropped with no warning at all (pages: CE1613 "The selected placeholder +-- '…Header' no longer exists"). +-- +-- Measured on a new 11.13.0 app: all 22 Atlas_Core layouts copied, plus pages +-- bound to the copied placeholders, build with 0 errors. +-- +-- mxcli exec this-file -p app.mpr && mxcli docker check -p app.mpr +-- ============================================================================ + +create or modify layout MyFirstModule.Copy_Phone_Sidebar ( + layouttype: 'Phone', + class: 'layout-atlas layout-atlas-phone' +) { + scrollcontainer scrollContainer1 { + region top (Class: 'region-topbar') { + layoutheader header1 { + region left { + sidebartoggle sidebarToggle2 ( + RenderMode: Link, + Tooltip: 'Toggle Menu', + Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', + Class: 'toggle-btn' + ) + } + region right { + placeholder HeaderRight + } + } + } + region left (Size: 80, SizeMode: 'Percentage', ToggleMode: 'PushContentAside', Class: 'region-sidebar') { + navigationtree navigationTree1 (Menu: Atlas_Core.Phone_Menu, Class: 'sidebar-menu') + } + region center (Class: 'region-content') { + placeholder Main + } + } +}; + +create or modify layout MyFirstModule.Copy_NativePhone_SideMenu ( + layouttype: 'Default' +) { + placeholder Main + region header { + placeholder Header + } + region sidebar { + placeholder Placeholder1 + } +}; diff --git a/mdl/backend/modelsdk/layout_write.go b/mdl/backend/modelsdk/layout_write.go index 241bed066..cdfca8f96 100644 --- a/mdl/backend/modelsdk/layout_write.go +++ b/mdl/backend/modelsdk/layout_write.go @@ -113,6 +113,24 @@ func layoutContentToGen(l *pages.Layout) (element.Element, error) { for _, w := range widgets { c.AddWidgets(w) } + // Every native Atlas layout stores all four, false and empty + // included (11.13.0); RightHeaderPlaceholder is null when unset. + c.SetShowBottomBar(l.ShowBottomBar) + c.SetSidebar(l.Sidebar) + for _, w := range l.SidebarWidgets { + wg, err := widgetToGen(w) + if err != nil { + return nil, err + } + c.AddSidebarWidgets(wg) + } + if l.HeaderPlaceholder != nil { + ph, err := widgetToGen(l.HeaderPlaceholder) + if err != nil { + return nil, err + } + c.SetRightHeaderPlaceholder(ph) + } return c, nil } c := genPg.NewWebLayoutContent() @@ -145,6 +163,10 @@ func layoutContentToGen(l *pages.Layout) (element.Element, error) { // DROP LAYOUT existed, with no way to undo the document mxcli had just written. func checkLayoutPlaceholders(l *pages.Layout) error { names := layoutPlaceholderNames(l.Widgets, nil) + if l.HeaderPlaceholder != nil { + names = append(names, l.HeaderPlaceholder.Name) + } + names = layoutPlaceholderNames(l.SidebarWidgets, names) // A layout built on another one declares its placeholders inside the // arguments it passes (mendixlabs/mxcli#1411). if l.LayoutCall != nil { @@ -199,6 +221,9 @@ func layoutPlaceholderNames(widgets []pages.Widget, acc []string) []string { acc = layoutPlaceholderNames(x.Widgets, acc) case *pages.GroupBox: acc = layoutPlaceholderNames(x.Widgets, acc) + case *pages.LayoutHeader: + acc = layoutPlaceholderNames(x.LeftWidgets, acc) + acc = layoutPlaceholderNames(x.RightWidgets, acc) } } return acc diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index f3738a1f5..049886d5d 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -141,6 +141,22 @@ func init() { NullFields: []string{"Icon", "ConditionalVisibilitySettings"}, }) codec.RegisterListMarker("Forms$SidebarToggleButton", 2) + // A native layout's content: RightHeaderPlaceholder is null when unset + // (NativePhone_FullPage) and SidebarWidgets always stored, marker 2 — + // 5 of 5 native Atlas_Core layouts (11.13.0). + codec.RegisterTypeDefaults("Forms$NativeLayoutContent", codec.TypeDefaults{ + NullFields: []string{"RightHeaderPlaceholder"}, + MandatoryListMarkers: map[string]int32{"SidebarWidgets": 2}, + }) + codec.RegisterPropertyListMarker("Forms$NativeLayoutContent", "SidebarWidgets", 2) + // Header: both widget lists always stored, marker 2 — 9 of 9 in Atlas_Core + // (11.13.0). + codec.RegisterTypeDefaults("Forms$Header", codec.TypeDefaults{ + MandatoryListMarkers: map[string]int32{"LeftWidgets": 2, "RightWidgets": 2}, + }) + codec.RegisterPropertyListMarker("Forms$Header", "LeftWidgets", 2) + codec.RegisterPropertyListMarker("Forms$Header", "RightWidgets", 2) + codec.RegisterListMarker("Forms$Header", 2) // Title: null visibility/accessibility slots; marker 2 as a widget. codec.RegisterTypeDefaults("Forms$Title", codec.TypeDefaults{ NullFields: []string{"ConditionalVisibilitySettings", "NativeAccessibilitySettings"}, @@ -831,6 +847,27 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetOrientation(orDefaultStr(string(x.Orientation), string(pages.MenuOrientationHorizontal))) return g, nil + case *pages.LayoutHeader: + // Forms$Header, a phone/tablet layout's header bar. gen also exposes + // the singular LeftWidget / RightWidget, deleted in 7.15 and never set. + g := genPg.NewHeader() + applyWidgetBase(g, &x.BaseWidget) + for _, w := range x.LeftWidgets { + wg, err := widgetToGen(w) + if err != nil { + return nil, err + } + g.AddLeftWidgets(wg) + } + for _, w := range x.RightWidgets { + wg, err := widgetToGen(w) + if err != nil { + return nil, err + } + g.AddRightWidgets(wg) + } + return g, nil + case *pages.SidebarToggleButton: // An action button's face without the action (mendixlabs/mxcli#1220). // Studio Pro always stores a CaptionTemplate, empty on Atlas's own. diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 9932a9c37..db57bcca8 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -452,6 +452,8 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) { widget, err = pb.buildSimpleMenuBarV3(w) case "sidebartoggle": widget, err = pb.buildSidebarToggleV3(w) + case "layoutheader": + widget, err = pb.buildLayoutHeaderV3(w) case "placeholder": widget, err = pb.buildPlaceholderV3(w) case "radiobuttons": @@ -3335,6 +3337,48 @@ func (pb *pageBuilder) buildScrollContainerV3(w *ast.WidgetV3) (pages.Widget, er return sc, nil } +// buildLayoutHeaderV3 builds a phone or tablet layout's header bar +// (Forms$Header). Its children are its two sides, spelled like a scroll +// container's regions: `region left { … }` and `region right { … }`. +func (pb *pageBuilder) buildLayoutHeaderV3(w *ast.WidgetV3) (pages.Widget, error) { + h := &pages.LayoutHeader{ + BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ + ID: model.ID(types.GenerateID()), + TypeName: "Forms$Header", + }, + Name: w.Name, + }, + } + seen := map[string]bool{} + for _, child := range w.Children { + side := strings.ToLower(child.Name) + if strings.ToLower(child.Type) != "region" || (side != "left" && side != "right") { + return nil, mdlerrors.NewValidation(fmt.Sprintf( + "layoutheader %q: its children are its two sides, `region left { … }` and `region right { … }`", w.Name)) + } + if seen[side] { + return nil, mdlerrors.NewValidation(fmt.Sprintf("layoutheader %q: region %q given twice", w.Name, side)) + } + seen[side] = true + for _, gc := range child.Children { + cw, err := pb.buildWidgetV3(gc) + if err != nil { + return nil, err + } + if side == "left" { + h.LeftWidgets = append(h.LeftWidgets, cw) + } else { + h.RightWidgets = append(h.RightWidgets, cw) + } + } + } + if err := pb.registerWidgetName(w.Name, h.ID); err != nil { + return nil, err + } + return h, nil +} + // regionToggleModes are the values of Forms$ScrollContainerRegion.ToggleMode: // how a sidebar toggle button opens and closes the region. var regionToggleModes = []string{ diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index ec79654a9..0f40abdf3 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -370,10 +370,16 @@ func describeLayout(ctx *ExecContext, name ast.QualifiedName) error { // output cannot be fed back in. rawData, _ := ctx.Backend.GetRawUnit(foundLayout.ID) header := layoutHeaderMDL(ctx, layoutTypeStr, foundLayout.Class, layoutOuterName(rawData)) + if native := nativeLayoutContent(rawData); native != nil { + if on, _ := native["ShowBottomBar"].(bool); on { + header += ",\n showbottombar: true" + } + } fmt.Fprintf(ctx.Output, "create or modify layout %s.%s (\n%s\n) {\n", modName, mdlIdent(foundLayout.Name), header) outputLayoutBodyMDL(ctx, rawData) + outputNativeLayoutPartsMDL(ctx, rawData) fmt.Fprint(ctx.Output, "};\n\n") return nil @@ -393,6 +399,52 @@ func layoutHeaderMDL(ctx *ExecContext, layoutType, class, outer string) string { return header } +// nativeLayoutContent returns a native layout's Forms$NativeLayoutContent, or +// nil for a web layout. +func nativeLayoutContent(rawData map[string]any) map[string]any { + content, _ := rawData["Content"].(map[string]any) + if t, _ := content["$Type"].(string); t != "Forms$NativeLayoutContent" { + return nil + } + return content +} + +// outputNativeLayoutPartsMDL writes what a native layout keeps beside its +// content tree: the placeholder on the header's right as `region header`, and +// the sidebar — Sidebar plus SidebarWidgets — as `region sidebar`. A copy that +// left them out lost a page's header and side menu with nothing printed. +func outputNativeLayoutPartsMDL(ctx *ExecContext, rawData map[string]any) { + content := nativeLayoutContent(rawData) + if content == nil { + return + } + if ph, ok := content["RightHeaderPlaceholder"].(map[string]any); ok { + fmt.Fprint(ctx.Output, " region header {\n") + for _, w := range parseRawWidget(ctx, ph) { + outputWidgetMDLV3(ctx, w, 2) + } + fmt.Fprint(ctx.Output, " }\n") + } + if on, _ := content["Sidebar"].(bool); !on { + return + } + var widgets []rawWidget + for _, w := range getBsonArrayElements(content["SidebarWidgets"]) { + if wMap, ok := w.(map[string]any); ok { + widgets = append(widgets, parseRawWidget(ctx, wMap)...) + } + } + if len(widgets) == 0 { + fmt.Fprint(ctx.Output, " region sidebar\n") + return + } + fmt.Fprint(ctx.Output, " region sidebar {\n") + for _, w := range widgets { + outputWidgetMDLV3(ctx, w, 2) + } + fmt.Fprint(ctx.Output, " }\n") +} + // layoutCallOf returns the call a layout built on another layout stores under // Content.LayoutCall, or nil for a layout with a content tree of its own (whose // LayoutCall is stored as null). diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 033cce2b4..a094d5529 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -320,6 +320,26 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { formatWidgetProps(ctx.Output, prefix, header, props, "\n") } + case "Forms$Header", "Pages$Header", "Forms$HeaderSide": + // A layout header and its two sides (the latter synthetic, like a + // scroll container's regions). + header := fmt.Sprintf("layoutheader %s", mdlIdent(w.Name)) + var props []string + if w.Type == "Forms$HeaderSide" { + header = "region " + w.Name + } else { + props = appendAppearanceProps(ctx, nil, w) + } + if len(w.Children) == 0 { + formatWidgetProps(ctx.Output, prefix, header, props, "\n") + break + } + formatWidgetProps(ctx.Output, prefix, header, props, " {\n") + for _, child := range w.Children { + outputWidgetMDLV3(ctx, child, indent+1) + } + fmt.Fprintf(ctx.Output, "%s}\n", prefix) + case "Forms$ScrollContainerRegion": // A synthetic wrapper: the slot is in Name, and `region top` is how MDL // spells it. 200/Auto is Studio Pro's untouched default, so emitting it diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 54182b158..3eeee111b 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -342,6 +342,22 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.Icon, widget.IconType, widget.IconCode = extractButtonIcon(w) return []rawWidget{widget} + case "Forms$Header", "Pages$Header": + // A phone/tablet layout's header bar: two widget lists, described as + // its two sides `region left` / `region right`. + for _, side := range []struct{ key, name string }{{"LeftWidgets", "left"}, {"RightWidgets", "right"}} { + child := rawWidget{Type: "Forms$HeaderSide", Name: side.name} + for _, c := range getBsonArrayElements(w[side.key]) { + if cMap, ok := c.(map[string]any); ok { + child.Children = append(child.Children, parseRawWidget(ctx, cMap, inheritedCtx)...) + } + } + if len(child.Children) > 0 { + widget.Children = append(widget.Children, child) + } + } + return []rawWidget{widget} + case "Forms$SidebarToggleButton", "Pages$SidebarToggleButton": // An action button's face without the action (mendixlabs/mxcli#1220). widget.Caption = extractButtonCaption(ctx, w) diff --git a/mdl/executor/cmd_pages_layout_v3.go b/mdl/executor/cmd_pages_layout_v3.go index b5437f120..94056efd9 100644 --- a/mdl/executor/cmd_pages_layout_v3.go +++ b/mdl/executor/cmd_pages_layout_v3.go @@ -25,9 +25,11 @@ func (pb *pageBuilder) buildLayoutV3(s *ast.CreateLayoutStmt) (*pages.Layout, er // does not declare it and no Studio Pro layout carries it. for k := range s.Properties { if !strings.EqualFold(k, "LayoutType") && !strings.EqualFold(k, "Class") && - !strings.EqualFold(k, "Style") && !strings.EqualFold(k, "Layout") { + !strings.EqualFold(k, "Style") && !strings.EqualFold(k, "Layout") && + !strings.EqualFold(k, "ShowBottomBar") { return nil, mdlerrors.NewValidation(fmt.Sprintf( - "layout %s: unknown property %q (a layout header takes layouttype, class, style and layout; "+ + "layout %s: unknown property %q (a layout header takes layouttype, class, style, layout "+ + "and, on a native layout, showbottombar; "+ "which placeholder is \"main\" is set by naming one Main, not by a property)", s.Name.String(), k)) } @@ -102,6 +104,10 @@ func (pb *pageBuilder) buildLayoutV3(s *ast.CreateLayoutStmt) (*pages.Layout, er if err != nil { return nil, err } + expanded, err = pb.applyNativeLayoutParts(s, layout, props, expanded) + if err != nil { + return nil, err + } for _, astWidget := range expanded { w, err := pb.buildWidgetV3(astWidget) if err != nil { @@ -113,6 +119,65 @@ func (pb *pageBuilder) buildLayoutV3(s *ast.CreateLayoutStmt) (*pages.Layout, er return layout, nil } +// applyNativeLayoutParts reads what a native layout keeps beside its content +// tree — `showbottombar: true`, `region header { placeholder X }` and +// `region sidebar { … }` — and returns the remaining top-level widgets. On a +// web layout every one of them is refused: Forms$WebLayoutContent has none. +func (pb *pageBuilder) applyNativeLayoutParts(s *ast.CreateLayoutStmt, layout *pages.Layout, props *ast.WidgetV3, widgets []*ast.WidgetV3) ([]*ast.WidgetV3, error) { + for k, v := range props.Properties { + if !strings.EqualFold(k, "ShowBottomBar") { + continue + } + if !layout.Native { + return nil, mdlerrors.NewValidation(fmt.Sprintf( + "layout %s: showbottombar is a native layout's property; a web layout has no bottom bar setting", s.Name.String())) + } + on, ok := v.(bool) + if !ok { + return nil, mdlerrors.NewValidation(fmt.Sprintf("layout %s: showbottombar takes true or false", s.Name.String())) + } + layout.ShowBottomBar = on + } + var rest []*ast.WidgetV3 + for _, w := range widgets { + if !strings.EqualFold(w.Type, "region") { + rest = append(rest, w) + continue + } + part := strings.ToLower(w.Name) + if !layout.Native || (part != "header" && part != "sidebar") { + return nil, mdlerrors.NewValidation(fmt.Sprintf( + "layout %s: a region at the top of a layout is a native layout's `region header { placeholder X }` "+ + "or `region sidebar { … }`; a web layout's regions belong to its scrollcontainer", s.Name.String())) + } + switch part { + case "header": + if layout.HeaderPlaceholder != nil || len(w.Children) != 1 || !strings.EqualFold(w.Children[0].Type, "placeholder") { + return nil, mdlerrors.NewValidation(fmt.Sprintf( + "layout %s: `region header` holds exactly one placeholder — the one on the header's right", s.Name.String())) + } + ph, err := pb.buildPlaceholderV3(w.Children[0]) + if err != nil { + return nil, err + } + layout.HeaderPlaceholder = ph.(*pages.LayoutPlaceholder) + case "sidebar": + if layout.Sidebar { + return nil, mdlerrors.NewValidation(fmt.Sprintf("layout %s: region sidebar given twice", s.Name.String())) + } + layout.Sidebar = true + for _, c := range w.Children { + cw, err := pb.buildWidgetV3(c) + if err != nil { + return nil, mdlerrors.NewBackend("build widget", err) + } + layout.SidebarWidgets = append(layout.SidebarWidgets, cw) + } + } + } + return rest, nil +} + // platformForLayoutType maps a layout type onto the platform that uses it. // Reports false for a value neither platform attests to, so a typo is an error // rather than a web layout with a meaningless type. diff --git a/mdl/executor/design_property_keyword_keys_test.go b/mdl/executor/design_property_keyword_keys_test.go index 1404b36d6..8cefdcdda 100644 --- a/mdl/executor/design_property_keyword_keys_test.go +++ b/mdl/executor/design_property_keyword_keys_test.go @@ -266,6 +266,7 @@ func TestKeywordStorageTypesMatchBuilder(t *testing.T) { menubar k_menubar (profile: 'Responsive') simplemenubar k_simplemenubar (profile: 'Responsive') sidebartoggle k_sidebartoggle + layoutheader k_layoutheader placeholder k_placeholder }` prog, errs := visitor.Build(src) diff --git a/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go b/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go index efaba8c35..7a02d7134 100644 --- a/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go +++ b/mdl/executor/sidebartoggle_copy_layout_pedapp_test.go @@ -159,3 +159,146 @@ func storedRegionToggleModes(t *testing.T, exec *Executor, qn string) map[string walk(bson.RawValue{Type: bson.TypeEmbeddedDocument, Value: u.Contents}) return modes } + +// Every Atlas_Core layout, copied with describe → create layout, keeps every +// widget it stores. Nine of them hold a Forms$Header that describe printed as +// NOT re-executable; two of those carry the sidebar toggle, so once a region's +// ToggleMode was copied the copies failed mx check with CE0611 "A sidebar +// toggle button is required if a region can toggle" (11.13.0), and the other +// seven lost the HeaderLeft / HeaderRight placeholders pages bind to. +func TestCopyEveryAtlasLayoutKeepsEveryWidget(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + ctx := exec.newExecContext(context.Background()) + layouts, err := ctx.Backend.ListLayouts() + if err != nil { + t.Fatal(err) + } + h, err := getHierarchy(ctx) + if err != nil { + t.Fatal(err) + } + var names []string + for _, l := range layouts { + if h.GetModuleName(h.FindModuleID(l.ContainerID)) == "Atlas_Core" { + names = append(names, l.Name) + } + } + sort.Strings(names) + if len(names) < 20 { + t.Fatalf("found only %d Atlas_Core layouts in the fixture: %v", len(names), names) + } + headers := 0 + for _, name := range names { + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeLayout, Name: ast.QualifiedName{Module: "Atlas_Core", Name: name}}); err != nil { + t.Fatalf("describe layout %s: %v", name, err) + } + described := languageHeader.ReplaceAllString(out.String(), "") + if strings.Contains(described, "NOT re-executable") { + t.Errorf("%s: describe still drops a widget:\n%s", name, described) + continue + } + copied := strings.Replace(described, "create or modify layout Atlas_Core."+name+" ", "create layout MyFirstModule.Copy_"+name+" ", 1) + if got := agreeCheck(t, exec, dir, "mdl 1;\n"+copied); len(got) != 0 { + t.Errorf("%s: check refuses the copy:\n%s", name, strings.Join(got, "\n")) + continue + } + if err := agreeExec(t, exec, "mdl 1;\n"+copied); err != nil { + t.Errorf("%s: exec of the copy: %v\n%s", name, err, copied) + continue + } + orig := storedWidgetTypes(t, exec, "Atlas_Core."+name) + copy := storedWidgetTypes(t, exec, "MyFirstModule.Copy_"+name) + if orig != copy { + t.Errorf("%s: the copy stores different widgets:\n copy: %s\n original: %s", name, copy, orig) + } + if strings.Contains(orig, "Forms$Header") { + headers++ + } + if a, b := storedContentShape(t, exec, "MyFirstModule.Copy_"+name), storedContentShape(t, exec, "Atlas_Core."+name); a != b { + t.Errorf("%s: the copy's content wrapper differs:\n copy: %s\n original: %s", name, a, b) + } + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeLayout, Name: ast.QualifiedName{Module: "MyFirstModule", Name: "Copy_" + name}}); err != nil { + t.Fatalf("describe copy of %s: %v", name, err) + } + again := strings.Replace(languageHeader.ReplaceAllString(out.String(), ""), "create or modify layout", "create layout", 1) + if again != copied { + t.Errorf("%s: the copy describes differently:\n--- first\n%s\n--- copy\n%s", name, copied, again) + } + om, cm := storedRegionToggleModes(t, exec, "Atlas_Core."+name), storedRegionToggleModes(t, exec, "MyFirstModule.Copy_"+name) + for slot, want := range om { + if cm[slot] != want { + t.Errorf("%s: region %s ToggleMode = %q in the copy, Studio Pro stores %q", name, slot, cm[slot], want) + } + } + } + if headers == 0 { + t.Error("no Atlas_Core layout in the fixture holds a Forms$Header; the header half of this test proves nothing") + } +} + +// storedWidgetTypes is the layout's widget $Types, with each widget's sorted +// key set, as one comparable string — so a dropped widget, an extra one, or one +// written with a different property set all show up. +func storedWidgetTypes(t *testing.T, exec *Executor, qn string) string { + t.Helper() + ctx := exec.newExecContext(context.Background()) + u, err := ctx.Backend.GetRawUnitByName("layout", qn) + if err != nil || u == nil { + t.Fatalf("raw layout %s: %v", qn, err) + } + var found []string + var walk func(v bson.RawValue) + walk = func(v bson.RawValue) { + switch v.Type { + case bson.TypeEmbeddedDocument: + doc := v.Document() + if typ := doc.Lookup("$Type").StringValue(); strings.HasPrefix(typ, "Forms$") && doc.Lookup("Name").Type == bson.TypeString { + found = append(found, typ+"{"+rawKeysOf(doc)+"}") + } + elems, _ := doc.Elements() + for _, e := range elems { + walk(e.Value()) + } + case bson.TypeArray: + vals, _ := v.Array().Values() + for _, x := range vals { + walk(x) + } + } + } + walk(bson.RawValue{Type: bson.TypeEmbeddedDocument, Value: u.Contents}) + sort.Strings(found) + return strings.Join(found, "\n ") +} + +// storedContentShape is the layout's Content wrapper — its keys and its +// primitive values — which is where a native layout keeps its header +// placeholder, bottom bar and sidebar. +func storedContentShape(t *testing.T, exec *Executor, qn string) string { + t.Helper() + ctx := exec.newExecContext(context.Background()) + u, err := ctx.Backend.GetRawUnitByName("layout", qn) + if err != nil || u == nil { + t.Fatalf("raw layout %s: %v", qn, err) + } + content, ok := bson.Raw(u.Contents).Lookup("Content").DocumentOK() + if !ok { + return "" + } + elems, _ := content.Elements() + var parts []string + for _, e := range elems { + switch v := e.Value(); v.Type { + case bson.TypeBoolean, bson.TypeString: + if e.Key() != "$ID" { + parts = append(parts, e.Key()+"="+v.String()) + } + default: + parts = append(parts, e.Key()) + } + } + sort.Strings(parts) + return strings.Join(parts, ",") +} diff --git a/mdl/executor/theme_reader.go b/mdl/executor/theme_reader.go index 87d25dde4..62293700e 100644 --- a/mdl/executor/theme_reader.go +++ b/mdl/executor/theme_reader.go @@ -217,6 +217,7 @@ var mdlKeywordStorageType = map[string]string{ "menubar": "Forms$MenuBar", "simplemenubar": "Forms$SimpleMenuBar", "sidebartoggle": "Forms$SidebarToggleButton", + "layoutheader": "Forms$Header", "placeholder": "Forms$Placeholder", } @@ -330,6 +331,7 @@ var storageTypeThemeKeys = map[string]string{ // A Button subclass, so Studio Pro applies Atlas's "Button" group — the // Size: Large Atlas_Default's toggle carries comes from it. "SidebarToggleButton": "Button", + "Header": "Header", "Placeholder": "Placeholder", "SnippetCallWidget": "SnippetCallWidget", } diff --git a/mdl/executor/validate_page_context.go b/mdl/executor/validate_page_context.go index e10ac528a..2af20091e 100644 --- a/mdl/executor/validate_page_context.go +++ b/mdl/executor/validate_page_context.go @@ -238,7 +238,7 @@ var alwaysNamedKinds = map[string]bool{ "container": true, "customcontainer": true, "groupbox": true, "tabcontainer": true, "layoutgrid": true, "navigationlist": true, "staticimage": true, "dynamicimage": true, "image": true, "scrollcontainer": true, "navigationtree": true, "menubar": true, - "simplemenubar": true, "sidebartoggle": true, "textfilter": true, "numberfilter": true, "dropdownfilter": true, + "simplemenubar": true, "sidebartoggle": true, "layoutheader": true, "textfilter": true, "numberfilter": true, "dropdownfilter": true, "datefilter": true, "dropdownsort": true, } diff --git a/mdl/executor/widget_keywords.go b/mdl/executor/widget_keywords.go index cfb1bbc86..385fd4084 100644 --- a/mdl/executor/widget_keywords.go +++ b/mdl/executor/widget_keywords.go @@ -22,7 +22,8 @@ var builderOwnedWidgetKeywords = map[string]bool{ "button": true, "actionbutton": true, "linkbutton": true, "tabcontainer": true, "tabpage": true, "groupbox": true, "scrollcontainer": true, "region": true, "navigationtree": true, "menubar": true, "simplemenubar": true, "sidebartoggle": true, - "placeholder": true, "radiobuttons": true, "navigationlist": true, "item": true, + "layoutheader": true, + "placeholder": true, "radiobuttons": true, "navigationlist": true, "item": true, "snippetcall": true, "footer": true, "header": true, "controlbar": true, "template": true, "filter": true, "staticimage": true, "dynamicimage": true, } diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 8567764e0..a1663982d 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -363,6 +363,7 @@ SCROLLREGION: R E G I O N; MENUBAR: M E N U B A R; SIMPLEMENUBAR: S I M P L E M E N U B A R; SIDEBARTOGGLE: S I D E B A R T O G G L E; +LAYOUTHEADER: L A Y O U T H E A D E R; NAVIGATIONTREE: N A V I G A T I O N T R E E; SNIPPETCALL: S N I P P E T C A L L; diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index b87a53105..e48964f58 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -450,6 +450,7 @@ widgetTypeV3 | MENUBAR | SIMPLEMENUBAR // Atlas phone bottom bar: renders a menu document (#573) | SIDEBARTOGGLE // Atlas's responsive sidebar toggle button (mendixlabs/mxcli#1220) + | LAYOUTHEADER // Forms$Header: a phone/tablet layout's header bar, `region left|right { … }` // Object-list container keywords for pluggable widgets (Phase 1 — #538). // Each is the singular form of a Type:"object"+IsList:true widget property // (e.g. Accordion groups → GROUP). Routed at executor time via the parent diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index ca26117a6..41dd13bb6 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -714,7 +714,7 @@ keyword // Layout structure. SCROLLREGION is spelled `region`, an ordinary enough // word that leaving it out of this rule would make `region` unusable as an // attribute or widget name anywhere in MDL. - | SCROLLCONTAINER | SCROLLREGION | NAVIGATIONTREE | MENUBAR | SIMPLEMENUBAR | SIDEBARTOGGLE + | SCROLLCONTAINER | SCROLLREGION | NAVIGATIONTREE | MENUBAR | SIMPLEMENUBAR | SIDEBARTOGGLE | LAYOUTHEADER // Object-list container keywords for pluggable widgets (#538) | GROUP | CUSTOMITEM | MARKER | DYNAMICMARKER | SERIES | LINE | SCALECOLOR | CUSTOMBUTTON | ALLOWEDFILEFORMAT diff --git a/sdk/pages/pages.go b/sdk/pages/pages.go index ddee62517..00606e8c6 100644 --- a/sdk/pages/pages.go +++ b/sdk/pages/pages.go @@ -81,6 +81,15 @@ type Layout struct { // Content.LayoutCall with Content.Widgets [] (mendixlabs/mxcli#1411). LayoutCall *LayoutCall `json:"layoutCall,omitempty"` + // The native-only parts of Forms$NativeLayoutContent, all stored on every + // native Atlas layout (11.13.0): the placeholder on the header's right + // (null on NativePhone_FullPage), whether the bottom bar shows, and the + // sidebar with its widgets. A copy that left them out lost them silently. + HeaderPlaceholder *LayoutPlaceholder `json:"headerPlaceholder,omitempty"` + ShowBottomBar bool `json:"showBottomBar,omitempty"` + Sidebar bool `json:"sidebar,omitempty"` + SidebarWidgets []Widget `json:"sidebarWidgets,omitempty"` + // MainPlaceholderID and Widget are the shallow forms older readers filled. MainPlaceholderID model.ID `json:"mainPlaceholderId,omitempty"` Widget Widget `json:"widget,omitempty"` diff --git a/sdk/pages/pages_widgets_advanced.go b/sdk/pages/pages_widgets_advanced.go index 7c1f01ba0..e82162ed5 100644 --- a/sdk/pages/pages_widgets_advanced.go +++ b/sdk/pages/pages_widgets_advanced.go @@ -106,6 +106,19 @@ type SidebarToggleButton struct { RenderMode ButtonRenderMode `json:"renderMode,omitempty"` } +// LayoutHeader is a phone or tablet layout's header bar (Forms$Header): two +// widget lists, left and right. Atlas's Phone_ and Tablet_ layouts keep their +// HeaderLeft / HeaderRight placeholders in it, and Phone_Sidebar / +// Tablet_Sidebar their sidebar toggle. +// +// Measured on Atlas_Core (11.13.0): Appearance, LeftWidgets, Name, +// RightWidgets, TabIndex, both lists marker 2. +type LayoutHeader struct { + BaseWidget + LeftWidgets []Widget `json:"leftWidgets,omitempty"` + RightWidgets []Widget `json:"rightWidgets,omitempty"` +} + // MenuOrientation represents menu orientation. type MenuOrientation string