From 2175fe601e46521211da4b2762bb7fa035d540ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:05:53 +0000 Subject: [PATCH 01/35] fix(widgets): write a hidden widget property at its declared default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Image widget mxcli authored on Mendix 11.13 failed the build with CE0463 "the definition of this widget has changed" — including the exact pluggablewidget form that mdl-examples/bug-tests/image-ce0463-stale-default.mdl documents as fixed (it was verified on 11.12). Case B, established before any hypothesis: baseline blank project, mxcli never ran -> 0 CE0463 ...and it ships 10 Studio Pro-authored widgets of the SAME widget id the same project + one mxcli-authored Image -> 1 CE0463, naming that widget So the tool is the variable, and those 10 are a known-good reference in the same project — better than a template extraction, and no Studio Pro needed. The exhaustive path diff against them came back with every path present on both sides and four differing values, two of which were content. The Type (PropertyTypes schema) subtree and the TypePointer -> PropertyKey mapping were identical. Two hypotheses were tested and falsified, recorded because the elimination is worth as much as the fix: - Key order. mxcli's widget node is not in the reference's alphabetical order, which IS a documented CE0463 cause. It is not this one: the same page's mxcli-authored Datagrid and Badge carry the identical key order and pass. - The width value. Authoring Width: 48 explicitly passes, so 48 is not rejected on its own. The cause is the interaction. The widget hides `width` when `widthUnit` is "auto", and a hidden property must hold its DECLARED default. The engine skipped the mapping for a hidden property, which leaves the widget TEMPLATE's captured value — image.json stores 48 while its own ValueType declares the default as 100. mxcli already knew. MDL-WIDGET10 says it in as many words — "a non-default value there fails the build with CE0463 (the default is "100")" — so `check` was refusing what `exec` emitted. The writer now reads widgetPropertyDefaults, the checker's own source, so the two cannot drift apart again. Skipping was never the invariant: the engine's comment states it correctly, "the hidden ones at their default, so hidden means default-valued, not absent", and skipping only coincides with that when the template happens to already be at the default. Where no default can be looked up (a datasource has none) it still skips, so the File Uploader pruning from #956 is unchanged. Verified end to end on 11.13: CE0463 gone, and stubbing the SetPrimitive back out brings it straight back. Reported as mxcli-formula1 FINDINGS §69/§142. --- .claude/skills/fix-issue.md | 1 + mdl/executor/widget_engine.go | 47 ++++- mdl/executor/widget_hidden_reset_test.go | 185 ++++++++++++++++++ ...widget_primitive_default_condition_test.go | 12 +- 4 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 mdl/executor/widget_hidden_reset_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 021278b06..e67e2a837 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -758,3 +758,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `mxcli run --local`/`--hub` sits at several hundred percent CPU for hours while the app is gone; `ps -o stat` shows the runtime JVM as `Z` (zombie) under it, `curl localhost:` returns `000`, and the hub preview URL still answers | `cmd/mxcli/docker/localboot.go` (`watchExit`, `alive`, `stopProcess`), `cmd/mxcli/docker/runlocal.go` (`waitForInterruptOrExit`, `runtimeStoppedError`) | Two mechanisms. (1) **Nothing ever called `Wait()`** on the runtime process, so an exited JVM stayed an unreaped zombie — and `alive()` asked `Signal(0)`, which **succeeds on a zombie**. Measured (Linux 6.18, go1.26): proc state `Z`, `Signal(0)` → nil; after `Wait()` → "process already finished". So the liveness check reported a runtime that had terminated itself hours earlier as alive. `Signal(0)` is only a correct liveness test *because* something reaps — removing `watchExit` silently breaks that line, which is why the control is on the reaper, not on `alive()`. (2) **After boot, `run` waited on a signal and nothing else**, so a correct answer had no one asking; it now waits on the signal OR `rt.Exited()` and returns a **non-zero** error, because returning 0 after the app has gone is what let a supervisor conclude all was well. **Why it happens at all**: the local standalone runtime uses a development licence with a maximum run time and terminates *itself* (measured lifetimes 3h52m and 5h07m — not a fixed number, and shorter than a working session); `runtimeExitReason` lifts that from the runtime's own log, and reports nothing rather than guessing when it cannot tell. **Two waiters on one process deadlock**, so `stopProcess` consults the reaper's channel instead of taking its own `Wait`. The CPU spin itself was NOT reproduced and is not claimed fixed by name — it lived in the tunnel client, under a supervisor blind to its dead child; what is fixed is the state it occurred in, since mxcli now exits and takes the tunnel with it. **Generalisable**: a 200 from a tunnelled URL is not evidence the app is alive — the tunnel outlives the runtime. Reported as mxcli-formula1 FINDINGS §60 | | After `mxcli test … --local`, an app another `mxcli run --local` is serving goes blank while still answering HTTP 200 (~1.7 KB, the Mendix SPA shell); the runtime log shows `Connector: 404 - file not found for file: dist%2Findex.js` and `deployment/web/dist` is gone | `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go` (`localTestDeployDir`), `cmd/mxcli/testrunner/runner.go` (`checkScratchDeploymentExists`) | A local test run already used its own ports and its own `_test` database — the code comment says why, verbatim — but shared the **deployment directory**, which is the one the *browser* reads. A headless test boot does not bundle the web client, so its build left the running app serving the shell over a 404: tests pass, run keeps running, app is blank, nothing reported at either end. **Detection was not the fix**: the two processes use different ports by design, so no port check can see it, and a lock file would only turn a silent blanking into a refusal. The test boot now builds into `/.mxcli/deployment-test/` — gitignored, already where the test runtime log lives — which makes the collision impossible. Note booting a runtime against the shared directory damages it even **without** a rebuild (the packaging step removes the bundle — FINDINGS §35, `ReportLostWebClientBundle`), so "reuse the dev loop's tree read-only" is not an alternative. Consequence to wire: `--skip-build` used to mean "reuse deployment/" and now has nothing until tests have run once, so it is refused with the reason rather than failing inside the runtime boot against a path the user never chose. Reported as mxcli-formula1 FINDINGS §62 | | A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — "absence from the documentation is not absence from the grammar" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → "widget *pages.X not yet supported by the modelsdk engine"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 | +| Every `IMAGE` widget mxcli authors on Mendix 11.13 fails `mx check` with **CE0463** "the definition of this widget has changed", including the exact `pluggablewidget` form an existing bug-test documents as fixed. `mxcli fix widgets` clears it | `mdl/executor/widget_engine.go` (`hiddenUnnamedProperties`, the mapping loop) | **Case B**, established before any hypothesis (diagnose-ce0463 Step 0): the baseline blank project ships **10 Studio Pro-authored widgets of the same widget id** and reports **0** CE0463; one mxcli-authored Image makes it 1. So the tool is the variable — and those 10 are a known-good reference in the same project, better than a template extraction. The exhaustive path diff came back with **every path present on both sides** and four differing values, two of them content; the `Type` (PropertyTypes schema) subtree and the `TypePointer`→`PropertyKey` mapping were identical. **Two hypotheses tested and falsified, which is why they are recorded**: (a) *key order* — mxcli's node is not in the reference's alphabetical order, a documented CE0463 cause, but the same page's mxcli-authored Datagrid and Badge share that order and pass; (b) *the width value* — authoring `Width: 48` explicitly passes, so 48 is not rejected. The cause is the interaction: the widget **hides `width` when `widthUnit` is "auto"**, and a hidden property must hold its DECLARED default. The engine *skipped* the mapping for a hidden property, which leaves the widget **template's captured value** — image.json holds 48 while its own `ValueType.DefaultValue` says 100. mxcli's own **MDL-WIDGET10** already said so verbatim ("a non-default value there fails the build with CE0463 (the default is \"100\")"), so `check` refused what `exec` emitted: the fix makes the writer read `widgetPropertyDefaults`, the checker's own source, so the two cannot disagree again. The engine's comment already stated the invariant correctly — "the hidden ones **at their default**, so hidden means default-valued, not absent" — and skipping only coincides with that when the template happens to be at the default. Where no default can be looked up (a datasource has none) it still skips, so #956's File Uploader pruning is unchanged. **Control**: stub the `SetPrimitive` and CE0463 returns end-to-end. Follow-on the fix *revealed* (CE0463 was masking it): the default `ImageType: image` needs an image-collection entry MDL cannot name, so the bare form builds to Mendix's own "No image selected." — now **MDL-WIDGET22** at check time, and it found the same breakage in four of the repo's own examples. Reported as mxcli-formula1 FINDINGS §69/§142 | diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index bc85fbedb..b94f6e0a9 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -312,20 +312,30 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* // 3. Apply property mappings. // // A property the widget's editorConfig hides under the current configuration - // is SKIPPED, so the widget template's default survives into the stored - // object — which is what Studio Pro stores for it. Measured on a Studio - // Pro-authored File Uploader 2.5.0: all 34 declared properties are stored, - // the hidden ones at their default, so "hidden" means default-valued, not - // absent. + // is written at its DECLARED DEFAULT. Measured on a Studio Pro-authored File + // Uploader 2.5.0: all 34 declared properties are stored, the hidden ones at + // their default, so "hidden" means default-valued, not absent. + // + // Writing the default rather than merely skipping the mapping is the whole + // point: skipping leaves the widget TEMPLATE's captured value, and a template + // holds whatever the widget it was extracted from was set to. Image's is + // width/height 48 against a declared default of 100, which made every Image + // mxcli authored fail with CE0463 — see hiddenUnnamedProperties. Where no + // default can be looked up (a datasource has none) the mapping is skipped, + // exactly as before. // // This only ever fires for a property the script did NOT name — an MDL // keyword shared by several properties (File Uploader routes both // `associatedFiles` and `associatedImages` from `DataSource:`, gated on // `uploadMode`). Naming a hidden property outright is still an error, raised // by MDL-WIDGET10 at check time rather than silently dropped here (#956). - hiddenSkip := e.hiddenUnnamedProperties(def, w) + hiddenSkip := e.hiddenUnnamedProperties(def, w, + widgetPropertyDefaults(e.pageBuilder.getProjectPath(), def.WidgetID)) for _, mapping := range mappings { - if hiddenSkip[strings.ToLower(mapping.PropertyKey)] { + if reset, hidden := hiddenSkip[strings.ToLower(mapping.PropertyKey)]; hidden { + if reset != "" && mapping.Operation == "primitive" { + builder.SetPrimitive(mapping.PropertyKey, reset) + } continue } ctx, err := e.resolveMapping(mapping, w) @@ -601,9 +611,26 @@ func (e *PluggableWidgetEngine) isPrimaryAttributeMapping(mapping PropertyMappin // explicitly is reported by MDL-WIDGET10 instead, so it is deliberately not // included here. // +// The KEY's presence means "hidden". The VALUE is the property's DECLARED +// default, which the caller must write, or "" when no default could be looked +// up — mxcli does not invent one. +// +// Skipping the property instead leaves the widget TEMPLATE's captured value in +// place, and a template captures whatever the widget it was extracted from +// happened to be set to. For the Image widget that is width/height 48 against a +// declared default of 100, so every Image mxcli authored failed the build with +// CE0463 — while mxcli's own MDL-WIDGET10 said, in as many words, that a hidden +// `width` must be "100" (mxcli-formula1 FINDINGS §69/§142). The invariant is +// default-VALUED, not skipped; the two only coincide when the template is +// already at the default. +// +// defaults is the declared-default map from widgetPropertyDefaults — the same +// source MDL-WIDGET10 reads, so the writer and the checker cannot disagree about +// what a default is, which is how this shipped. +// // Rules come from the .def.json, falling back to a live lift from the installed // .mpk (the same two sources the visibility application uses). -func (e *PluggableWidgetEngine) hiddenUnnamedProperties(def *WidgetDefinition, w *ast.WidgetV3) map[string]bool { +func (e *PluggableWidgetEngine) hiddenUnnamedProperties(def *WidgetDefinition, w *ast.WidgetV3, defaults map[string]string) map[string]string { rules := def.PropertyVisibility if len(rules) == 0 { rules = resolveWidgetVisibilityRules(e.pageBuilder.getProjectPath(), def.WidgetID) @@ -612,7 +639,7 @@ func (e *PluggableWidgetEngine) hiddenUnnamedProperties(def *WidgetDefinition, w return nil } values, explicit := widgetValueMap(w, def) - out := map[string]bool{} + out := map[string]string{} for _, rule := range rules { if rule.Nested() || rule.HiddenWhen == nil { continue @@ -626,7 +653,7 @@ func (e *PluggableWidgetEngine) hiddenUnnamedProperties(def *WidgetDefinition, w continue // condition indeterminable — never guess } if rule.HiddenWhen.Hidden(map[string]string{rule.HiddenWhen.PropertyKey: condVal}) { - out[key] = true + out[key] = defaults[defaultsKey("", rule.PropertyKey)] } } return out diff --git a/mdl/executor/widget_hidden_reset_test.go b/mdl/executor/widget_hidden_reset_test.go new file mode 100644 index 000000000..1ad9d70a0 --- /dev/null +++ b/mdl/executor/widget_hidden_reset_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mxcli-formula1 FINDINGS §69/§142: every Image widget mxcli authored on Mendix +// 11.13 failed the build with CE0463 "the definition of this widget has changed". +// +// Established first, because the skill says to and because it decides whose bug +// it is (diagnose-ce0463.md, Step 0): +// +// baseline blank project, mxcli never ran -> 0 CE0463 +// …and it ships 10 Studio Pro-authored widgets of the SAME widget id, which pass +// the same project + one mxcli-authored Image -> 1 CE0463, naming that widget +// +// So the tool is the variable: Case B, mxcli emits something the package does not +// accept. +// +// The exhaustive path diff against those Studio Pro widgets came back with every +// path present on both sides and four differing values, two of which were content +// (the widget's Name, and the image it points at). What remained: +// +// widthUnit mine='auto' reference='pixels' +// heightUnit mine='auto' reference='pixels' +// +// and a before/after diff of `mx update-widgets` (which clears the error) changed +// exactly two values: width and height, 48 -> 100. +// +// Two hypotheses were tested and FALSIFIED, which is why they are written down: +// +// - Key order. mxcli's widget node is not in the reference's alphabetical order. +// It is a documented CE0463 cause, and it is not this one: the same page's +// mxcli-authored Datagrid and Badge carry the identical key order and pass. +// - The width VALUE. Authoring Width: 48 explicitly passes, so 48 is not +// rejected. (Authoring it explicitly is also what makes the property visible.) +// +// The cause is the interaction. The Image widget hides `width` when `widthUnit` +// is "auto", and a hidden property must hold its DECLARED DEFAULT — mxcli's own +// MDL-WIDGET10 says so, in as many words: +// +// property `width` is hidden when `widthUnit` is "auto" — a non-default value +// there fails the build with CE0463 (the default is "100") +// +// The engine skipped the mapping for a hidden property, which leaves the widget +// TEMPLATE's captured value in place. For Image that value is 48, and the +// declared default is 100. So `mxcli check` refused what `mxcli exec` emitted by +// default — and the template alone is enough to see it: image.json's own +// ValueType declares width's DefaultValue as "100" while its Object stores "48". +// +// Skipping was never the invariant. The engine's own comment states it correctly +// — "all 34 declared properties are stored, the hidden ones AT THEIR DEFAULT, so +// hidden means default-valued, not absent" — and then skips, which only coincides +// with the default when the template happens to have captured it. + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// imageDef is the Image widget reduced to the properties this is about: the unit +// that does the hiding, and the dimension it hides. +func imageDef() *WidgetDefinition { + return &WidgetDefinition{ + WidgetID: "com.mendix.widget.web.image.Image", + MDLName: "image", + PropertyMappings: []PropertyMapping{ + {PropertyKey: "widthUnit", Source: "WidthUnit", Operation: "primitive", Value: "auto"}, + {PropertyKey: "width", Source: "Width", Operation: "primitive"}, + {PropertyKey: "heightUnit", Source: "HeightUnit", Operation: "primitive", Value: "auto"}, + {PropertyKey: "height", Source: "Height", Operation: "primitive"}, + }, + PropertyVisibility: []types.WidgetVisibilityRule{ + {PropertyKey: "width", HiddenWhen: &types.WidgetVisibilityCondition{ + PropertyKey: "widthUnit", Operator: "eq", Value: "auto"}}, + {PropertyKey: "height", HiddenWhen: &types.WidgetVisibilityCondition{ + PropertyKey: "heightUnit", Operator: "eq", Value: "auto"}}, + }, + } +} + +// The declared defaults, as widgetPropertyDefaults lifts them from the installed +// .mpk. Measured on the 11.13 Image widget. +func imageDefaults() map[string]string { + return map[string]string{"width": "100", "height": "100", "widthunit": "auto", "heightunit": "auto"} +} + +// The reported case: `image img (...)` with no dimensions. Both hidden properties +// must be RESET to their declared default, not left at whatever the template +// captured. +func TestHiddenProperties_ResetToTheirDeclaredDefault(t *testing.T) { + e := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + hidden := e.hiddenUnnamedProperties(imageDef(), &ast.WidgetV3{Name: "img"}, imageDefaults()) + + for _, key := range []string{"width", "height"} { + reset, ok := hidden[key] + if !ok { + t.Fatalf("%s is hidden when its unit is auto and was not pruned at all", key) + } + if reset != "100" { + t.Errorf("%s reset value = %q, want %q — skipping leaves the template's 48, "+ + "which is the CE0463", key, reset, "100") + } + } +} + +// CONTROL 1: a property that is NOT hidden must not be touched. A reset applied +// to a visible property would overwrite what the user asked for. +func TestHiddenProperties_VisiblePropertyIsNotReset(t *testing.T) { + e := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + w := &ast.WidgetV3{Name: "img", Properties: map[string]any{"WidthUnit": "pixels", "Width": "48"}} + + hidden := e.hiddenUnnamedProperties(imageDef(), w, imageDefaults()) + if _, ok := hidden["width"]; ok { + t.Error("width is visible when widthUnit is pixels — resetting it would discard Width: 48") + } + // …and the other axis is independent: heightUnit is still auto here. + if _, ok := hidden["height"]; !ok { + t.Error("height is still hidden (heightUnit defaults to auto) and must still be reset") + } +} + +// CONTROL 2: a property the SCRIPT named is left to MDL-WIDGET10, which reports +// it as an error at check time. Silently resetting it would discard the user's +// value and turn a diagnosable mistake into a mystery. +func TestHiddenProperties_ExplicitlyNamedIsLeftToTheChecker(t *testing.T) { + e := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + w := &ast.WidgetV3{Name: "img", Properties: map[string]any{"Width": "48"}} + + if _, ok := e.hiddenUnnamedProperties(imageDef(), w, imageDefaults())["width"]; ok { + t.Error("a hidden property the script named must not be silently reset — MDL-WIDGET10 reports it") + } +} + +// CONTROL 3: with no declared default, the property is still pruned but carries +// no reset. mxcli does not invent a value it could not look up; skipping is the +// old behaviour and remains the fallback. +func TestHiddenProperties_NoDeclaredDefaultMeansNoReset(t *testing.T) { + e := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + hidden := e.hiddenUnnamedProperties(imageDef(), &ast.WidgetV3{Name: "img"}, nil) + + reset, ok := hidden["width"] + if !ok { + t.Fatal("the property is hidden regardless of whether a default could be found") + } + if reset != "" { + t.Errorf("reset = %q, want empty — no default was available to reset to", reset) + } +} + +// The File Uploader case this mechanism was built for (#956) must keep working: +// there the pruned property is a DATASOURCE, which has no declared default, so it +// is skipped exactly as before. +func TestHiddenProperties_DataSourcePruningIsUnchanged(t *testing.T) { + e := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + def := uploadModeDef() + + filesMode := e.hiddenUnnamedProperties(def, &ast.WidgetV3{ + Name: "fu", Properties: map[string]any{"DataSource": "assoc"}}, nil) + if _, ok := filesMode["associatedimages"]; !ok { + t.Error("associatedImages was not pruned under the default uploadMode — this is #956's CE0463") + } + if _, ok := filesMode["associatedfiles"]; ok { + t.Error("associatedFiles was pruned under uploadMode files — the widget would lose its data source") + } +} + +// The writer and the checker must read the SAME declared defaults, or mxcli can +// author what it then refuses. That is exactly what happened here: MDL-WIDGET10 +// knew the default was "100" while the writer emitted the template's 48. +func TestHiddenProperties_WriterAndCheckerShareTheDefaultsSource(t *testing.T) { + // widgetPropertyDefaults is the checker's source (validate_widget_hidden.go). + // With no project it yields nothing, which is the "cannot look it up" case — + // the point here is that the writer calls the same function, so the two can + // never disagree about what a default is. + if got := widgetPropertyDefaults("", "com.mendix.widget.web.image.Image"); len(got) != 0 { + t.Fatalf("expected no defaults without a project, got %d", len(got)) + } + e := &PluggableWidgetEngine{pageBuilder: &pageBuilder{}} + hidden := e.hiddenUnnamedProperties(imageDef(), &ast.WidgetV3{Name: "img"}, + widgetPropertyDefaults("", "com.mendix.widget.web.image.Image")) + if reset := hidden["width"]; reset != "" { + t.Errorf("reset = %q, want empty when the defaults source has nothing", reset) + } +} diff --git a/mdl/executor/widget_primitive_default_condition_test.go b/mdl/executor/widget_primitive_default_condition_test.go index f53abf779..e251cc078 100644 --- a/mdl/executor/widget_primitive_default_condition_test.go +++ b/mdl/executor/widget_primitive_default_condition_test.go @@ -67,20 +67,20 @@ func TestHiddenUnnamedProperties_PrunesTheInactiveDataSource(t *testing.T) { def := uploadModeDef() filesMode := e.hiddenUnnamedProperties(def, &ast.WidgetV3{ - Name: "fu", Properties: map[string]any{"DataSource": "assoc"}}) - if !filesMode["associatedimages"] { + Name: "fu", Properties: map[string]any{"DataSource": "assoc"}}, nil) + if _, ok := filesMode["associatedimages"]; !ok { t.Error("associatedImages was not pruned under the default uploadMode — this is the CE0463") } - if filesMode["associatedfiles"] { + if _, ok := filesMode["associatedfiles"]; ok { t.Error("associatedFiles was pruned under uploadMode files — the widget would lose its data source") } imagesMode := e.hiddenUnnamedProperties(def, &ast.WidgetV3{ - Name: "fu", Properties: map[string]any{"uploadMode": "images", "DataSource": "assoc"}}) - if !imagesMode["associatedfiles"] { + Name: "fu", Properties: map[string]any{"uploadMode": "images", "DataSource": "assoc"}}, nil) + if _, ok := imagesMode["associatedfiles"]; !ok { t.Error("associatedFiles was not pruned under uploadMode images") } - if imagesMode["associatedimages"] { + if _, ok := imagesMode["associatedimages"]; ok { t.Error("associatedImages was pruned under uploadMode images — CE0642, the property is required there") } } From ccb4fd5183a5fe0d0ee88b316ba0f7941e194f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:06:05 +0000 Subject: [PATCH 02/35] feat(check): report an image widget with no image (MDL-WIDGET22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by fixing the Image CE0463: with the definition error out of the way, the bare form's real problem is visible. image i (Responsive: false) -> [error] "No image selected." at Image 'i' The Image widget's `datasource` defaults to `image`, an entry from an image collection — and MDL cannot name one: the widget's `imageObject` property is of type `image`, an operation the pluggable widget engine does not implement. So the default spelling of `image` always writes a model mxbuild refuses. CE0463 was masking it, which is why it went unnoticed for so long: the definition error fired first, and `mx update-widgets` "fixed" the page by clearing that one and leaving the real error behind. It is also what makes a describe -> exec copy of an Atlas layout lose its brand image. Reported at check time because that costs a second rather than a build, and as an ERROR because the build does not pass — accepting it would be mxcli waving through a script it knows produces a broken app. The message quotes what mxbuild says and the suggestion names the spelling that does work. The rule immediately found the same breakage in four of this repo's own examples, which are corrected here rather than the rule weakened. One of them, P_Image_OnClick, could not simply be given a URL: `DisplayAs` is hidden unless the source is an image collection, so setting both is CE0463 — MDL-WIDGET10 caught that during the edit. It now demonstrates the URL source without DisplayAs, and says why. Verified: the corrected doctype script applies 12 statements and mx check reports no new errors. --- cmd/mxcli/syntax/features_page.go | 9 +- .../bug-tests/widget-unknown-property.mdl | 3 +- .../17-custom-widget-examples.mdl | 14 ++- mdl/executor/validate_widget_image.go | 83 +++++++++++++ mdl/executor/validate_widget_image_test.go | 116 ++++++++++++++++++ mdl/executor/validate_widgets.go | 3 + 6 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 mdl/executor/validate_widget_image.go create mode 100644 mdl/executor/validate_widget_image_test.go diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 79ab07c45..cabbb3e2e 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -51,11 +51,10 @@ func init() { "-- Inputs\nTEXTBOX name (Label: 'L', Attribute: Attr)\nTEXTAREA | DATEPICKER | COMBOBOX | CHECKBOX | RADIOBUTTONS\n\n" + "-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE_CHANGES, ButtonStyle: Primary)\nLINKBUTTON name (Caption: 'C', Action: ...)\n\n" + "-- Display\nDYNAMICTEXT name (Content: 'Hello, {1}!', ContentParams: [{1} = Name])\nTITLE name (Content: 'Heading')\nIMAGE name (ImageType: imageUrl, ImageUrl: 'https://…')\n" + - "-- Note: on Mendix 11.13 an authored Image reports CE0463 \"the definition of this\n" + - "-- widget has changed\" until you run `mxcli fix widgets -p app.mpr`, which clears it\n" + - "-- and preserves MPR v2 (measured: 2 errors -> 0, 393 .mxunit files unchanged).\n" + - "-- mxcli's embedded widget templates are the 11.6 set; `mx update-widgets` is the\n" + - "-- same remedy but collapses MPR v2 to v1, so prefer `mxcli fix widgets`.\n\n" + + "-- IMAGE needs a source. Its default, `ImageType: image`, shows an entry from an\n" + + "-- image collection — which MDL cannot yet name, so the bare form writes a model\n" + + "-- mxbuild refuses (\"No image selected.\"). MDL-WIDGET22 reports that at check\n" + + "-- time. Use the URL form above, or `ImageType: icon`.\n\n" + "-- Any pluggable widget by its id (id FIRST, then the name)\nPLUGGABLEWIDGET 'com.mendix.widget.web.badge.Badge' name (value: 'x')\nCUSTOMWIDGET 'com.mendix.widget.custom.x.X' name (prop: 'x') -- legacy spelling\n\n" + "-- Accepted by the parser, NOT writable on the default engine.\n" + "-- Measured on 11.13.0: each is refused with\n" + diff --git a/mdl-examples/bug-tests/widget-unknown-property.mdl b/mdl-examples/bug-tests/widget-unknown-property.mdl index 6950d170b..afde96bea 100644 --- a/mdl-examples/bug-tests/widget-unknown-property.mdl +++ b/mdl-examples/bug-tests/widget-unknown-property.mdl @@ -31,5 +31,6 @@ create or replace page MyFirstModule.P_UnknownProp -- genuinely unknown property → MDL-WIDGET07 (no suggestion) dynamictext unknown ( Content: 'hi', TotallyMadeUp: 'x' ) -- describe-vocabulary props on a native image → no warning - image img ( WidthUnit: pixels, Width: 36, HeightUnit: pixels, Height: 36 ) + image img ( ImageType: imageUrl, ImageUrl: 'https://example.com/x.png', + WidthUnit: pixels, Width: 36, HeightUnit: pixels, Height: 36 ) } diff --git a/mdl-examples/doctype-tests/17-custom-widget-examples.mdl b/mdl-examples/doctype-tests/17-custom-widget-examples.mdl index 208476215..4bec3b8b3 100644 --- a/mdl-examples/doctype-tests/17-custom-widget-examples.mdl +++ b/mdl-examples/doctype-tests/17-custom-widget-examples.mdl @@ -233,7 +233,10 @@ create page CWTest.P_Image_Basic layoutgrid lgMain { row row1 { column col1 (desktopwidth: 12) { - image imgLogo + -- An image needs a SOURCE. The widget's default source is an image + -- collection entry, which MDL cannot yet name, so the bare form builds + -- to "No image selected." (MDL-WIDGET22). The URL form is complete. + image imgLogo (ImageType: imageUrl, ImageUrl: 'https://example.com/logo.png') } } } @@ -263,6 +266,8 @@ create page CWTest.P_Image_Dimensions row row1 { column col1 (desktopwidth: 12) { image imgBanner ( + ImageType: imageUrl, + ImageUrl: 'https://example.com/banner.png', AlternativeText: 'Company banner', WidthUnit: pixels, width: 800, @@ -302,9 +307,14 @@ create page CWTest.P_Image_OnClick layoutgrid lgMain { row row1 { column col1 (desktopwidth: 6) { + -- DisplayAs is NOT set here on purpose: the widget hides it unless + -- the source is an image collection entry, and MDL cannot name one + -- (MDL-WIDGET22), so this page uses the URL source. Setting DisplayAs + -- alongside it is CE0463, which MDL-WIDGET10 reports at check time. image imgTask ( + ImageType: imageUrl, + ImageUrl: 'https://example.com/task.png', AlternativeText: 'Task image', - DisplayAs: thumbnail, WidthUnit: pixels, width: 300 ) diff --git a/mdl/executor/validate_widget_image.go b/mdl/executor/validate_widget_image.go new file mode 100644 index 000000000..6282eb120 --- /dev/null +++ b/mdl/executor/validate_widget_image.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// MDL-WIDGET22: an IMAGE widget with nothing to show. +// +// The Image widget's `datasource` property defaults to `image` — an image from +// an image collection — and MDL has no way to say WHICH image: the widget's +// `imageObject` property is of type `image`, an operation the pluggable widget +// engine does not implement. So the default spelling +// +// image i (Responsive: false) +// +// writes a model mxbuild refuses: +// +// [error] "No image selected." at Image 'i' +// +// This was invisible until the CE0463 fix landed. Every mxcli-authored Image +// failed the build with CE0463 "the definition of this widget has changed" — +// which fired first, and which `mx update-widgets` cleared, leaving the real +// error behind it (mxcli-formula1 FINDINGS §69/§142). It is also why a +// describe → exec copy of an Atlas layout loses its brand image. +// +// Reported at check time because that is where it costs a second rather than a +// build. It is an ERROR: the build does not pass, so accepting it would be +// mxcli passing a script it knows produces a broken app. +const imageSourceRule = "MDL-WIDGET22" + +// imageSourceNeedsImage lists the `datasource` values that require an image +// reference MDL cannot yet author. Anything else — including a value this build +// does not recognise — is left alone rather than guessed at. +var imageSourceNeedsImage = map[string]bool{ + "": true, // absent: the widget's own default is "image" + "image": true, +} + +// validateImageSource reports an IMAGE widget whose configured source has no +// value to render. +func validateImageSource(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + if w == nil || !strings.EqualFold(w.Type, "image") { + return nil + } + source := strings.TrimSpace(w.GetStringProp("ImageType")) + + switch { + case imageSourceNeedsImage[strings.ToLower(source)]: + return []linter.Violation{{ + RuleID: imageSourceRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: widget `%s` (image) shows an image from an image collection "+ + "(the default `ImageType: image`) but names no image — mxbuild rejects the "+ + "build with \"No image selected.\"", + locationPrefix, w.Name), + Location: linter.Location{DocumentType: "page"}, + Suggestion: "MDL cannot yet point an image widget at an image collection entry " + + "(the widget's `imageObject` property is of a type the widget engine does not " + + "author). Use the URL form instead — `image " + w.Name + + " (ImageType: imageUrl, ImageUrl: 'https://…')` — or `ImageType: icon`, or set " + + "the image in Studio Pro.", + }} + case strings.EqualFold(source, "imageUrl") && strings.TrimSpace(w.GetStringProp("ImageUrl")) == "": + return []linter.Violation{{ + RuleID: imageSourceRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: widget `%s` (image) has `ImageType: imageUrl` and no `ImageUrl` — "+ + "mxbuild rejects the build with \"No image selected.\"", + locationPrefix, w.Name), + Location: linter.Location{DocumentType: "page"}, + Suggestion: "Give it a URL: `ImageUrl: 'https://…'`.", + }} + } + return nil +} diff --git a/mdl/executor/validate_widget_image_test.go b/mdl/executor/validate_widget_image_test.go new file mode 100644 index 000000000..66cf86f81 --- /dev/null +++ b/mdl/executor/validate_widget_image_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// Found while fixing the Image CE0463 (mxcli-formula1 FINDINGS §69/§142): with +// CE0463 out of the way, the bare form's real problem is visible. +// +// image i (Responsive: false) +// → [error] "No image selected." at Image 'i' +// +// The Image widget's `datasource` defaults to `image`, which shows an image from +// an image collection — and MDL has no way to say WHICH image (the widget's +// `imageObject` property is of type `image`, an operation the widget engine does +// not implement). So the default spelling of `image` always writes a model +// mxbuild rejects. +// +// CE0463 was masking that, which is why it went unnoticed: the definition error +// fired first and `mx update-widgets` "fixed" the page by clearing it, leaving +// the real error behind. The same gap is what makes a describe → exec copy of an +// Atlas layout lose its brand image (§142). +// +// Reporting it at check time is the whole value: mxbuild reports it at the far +// end of a build, and `mxcli check` runs in a second. + +func imageWidget(props map[string]any) *ast.WidgetV3 { + return &ast.WidgetV3{Name: "img", Type: "image", Properties: props} +} + +func imageViolations(vs []linter.Violation) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == "MDL-WIDGET22" { + out = append(out, v) + } + } + return out +} + +// The reported case: the default data source with no image to show. +func TestImageWidget_DefaultSourceWithNoImageIsReported(t *testing.T) { + got := imageViolations(validateImageSource(imageWidget(map[string]any{"Responsive": "false"}), "page X")) + if len(got) != 1 { + t.Fatalf("got %d violations, want 1: %+v", len(got), got) + } + if got[0].Severity != linter.SeverityError { + t.Errorf("severity = %v, want error — mxbuild refuses the build", got[0].Severity) + } + if !strings.Contains(got[0].Message, "No image selected") { + t.Errorf("the message should quote what mxbuild says: %s", got[0].Message) + } + // It must offer the spelling that does work, not just refuse. + if !strings.Contains(got[0].Suggestion, "imageUrl") { + t.Errorf("the suggestion should name the working form: %s", got[0].Suggestion) + } +} + +// …and with an explicit ImageType: image, which is the same model. +func TestImageWidget_ExplicitImageTypeWithNoImageIsReported(t *testing.T) { + w := imageWidget(map[string]any{"ImageType": "image"}) + if got := imageViolations(validateImageSource(w, "page X")); len(got) != 1 { + t.Fatalf("got %d violations, want 1: %+v", len(got), got) + } +} + +// CONTROL 1: the URL form is complete and must pass. This is the shape the +// CE0463 fix was verified on, at 0 errors. +func TestImageWidget_UrlFormIsClean(t *testing.T) { + w := imageWidget(map[string]any{"ImageType": "imageUrl", "ImageUrl": "https://example.com/x.png"}) + if got := imageViolations(validateImageSource(w, "page X")); len(got) != 0 { + t.Errorf("the URL form builds at 0 errors and must not be reported: %+v", got) + } +} + +// CONTROL 2: an icon source needs no image either. +func TestImageWidget_IconFormIsClean(t *testing.T) { + w := imageWidget(map[string]any{"ImageType": "icon"}) + if got := imageViolations(validateImageSource(w, "page X")); len(got) != 0 { + t.Errorf("an icon source needs no image: %+v", got) + } +} + +// CONTROL 3: the rule is about the IMAGE widget only. A different widget with a +// stray ImageType property is none of its business. +func TestImageWidget_OtherWidgetsAreNotTouched(t *testing.T) { + w := &ast.WidgetV3{Name: "dg", Type: "datagrid", Properties: map[string]any{}} + if got := imageViolations(validateImageSource(w, "page X")); len(got) != 0 { + t.Errorf("a datagrid was reported: %+v", got) + } +} + +// CONTROL 4: an ImageType nobody recognises is left alone rather than guessed +// at. Reporting on an unknown source would fire on a widget version this build +// does not know about. +func TestImageWidget_UnknownImageTypeIsLeftAlone(t *testing.T) { + w := imageWidget(map[string]any{"ImageType": "somethingNew"}) + if got := imageViolations(validateImageSource(w, "page X")); len(got) != 0 { + t.Errorf("an unrecognised ImageType was reported: %+v", got) + } +} + +// The URL form with an EMPTY url is the same incompleteness by the other route, +// and mxbuild rejects it too. +func TestImageWidget_EmptyUrlIsReported(t *testing.T) { + w := imageWidget(map[string]any{"ImageType": "imageUrl", "ImageUrl": ""}) + if got := imageViolations(validateImageSource(w, "page X")); len(got) != 1 { + t.Fatalf("got %d violations, want 1: %+v", len(got), got) + } +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index e5a652d42..e3628d8d8 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -122,6 +122,9 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validatePluggableContentParams(w, locationPrefix)...) } out = append(out, validateWidgetVisibility(w, registry, locationPrefix)...) + // An IMAGE with nothing to show — the default source needs an image + // reference MDL cannot author. See validate_widget_image.go. + out = append(out, validateImageSource(w, locationPrefix)...) out = append(out, validateStaticWidget(w, locationPrefix)...) out = append(out, validateDynamicTextFormatting(w, locationPrefix)...) out = append(out, validateDatasourceXPathAssociationEmpty(w, locationPrefix)...) From 99c17121c460eab350c6148e5ea857bff180e740 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:22:33 +0000 Subject: [PATCH 03/35] fix(xpath): fold XPath operator keywords to lower case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RETRIEVE ... WHERE a = $Var/Attr AND b = $Var/Attr` passed `mxcli check` and failed the build: ERROR at Formula1Backend, Microflow 'ZZ_AndProbe2', Retrieve object(s) activity 'Retrieve list of LiveForecast from database': Error(s) in XPath constraint. (CE0161) BUILD FAILED XPath 1.0 spells its operators in lower case only, and MDL's lexer accepts any case (`AND: A N D;`), so the two have to be reconciled on the way out. mxcli already did that — on one of its two rendering paths. expressionToXPath lowercases the operator while walking the parse tree; but buildRetrieveWhereExpression freezes the RAW SOURCE whenever the clause contains a `/`, which every variable path does, and expressionToXPath's SourceExpr case hands that text straight back. So the casing survived exactly when a variable was present. That correlation is the whole difficulty, and the reporting project got there first: their earlier note blamed the casing alone, and the literal-only reproducer they wrote to file it upstream BUILT CLEANLY. Measured, all three: WHERE SessionKey = '1' AND AtLap = 2 -> stored `and`, builds WHERE SessionKey = $N/SessionKey AND AtLap = $N/AtLap -> stored `AND`, CE0161 ...the same with lowercase `and` -> builds Fixed at FormatXPathConstraint, the single choke point all three constraint writers share — retrieve, page data source and entity access rule. The latter two were broken in the same way and measured as such before the fix, which is why the fix is not in the retrieve builder. It runs BEFORE the width test, because that branch returns the caller's own bytes and is where the casing hid. The replacement is token-based and string-literal-aware. Both are cases where being careless would be a worse bug than the one being fixed: rewriting inside `'A AND B'` silently changes which rows the constraint matches, and an identifier that merely contains the letters (Brand, Andrew, NOTES, Order_Andon, Module.Handover) is not an operator. `div` and `mod` are XPath keywords too and are deliberately left out — nothing in MDL emits them, and a rewrite nothing needs can only be wrong. Verified on 11.13: the example applies 8 statements and mx check reports no CE0161, with `'A AND B'` intact inside its quotes; stubbing the normalisation back out returns the uppercase operator in all three writers. Reported as mxcli-formula1 FINDINGS §80. --- .claude/skills/fix-issue.md | 1 + .../bug-tests/f1-80-xpath-operator-case.mdl | 87 ++++++++++ mdl/visitor/xpath_format.go | 6 + mdl/visitor/xpath_operators.go | 132 +++++++++++++++ mdl/visitor/xpath_operators_test.go | 158 ++++++++++++++++++ 5 files changed, 384 insertions(+) create mode 100644 mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl create mode 100644 mdl/visitor/xpath_operators.go create mode 100644 mdl/visitor/xpath_operators_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index e67e2a837..8d925e5ef 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -759,3 +759,4 @@ extracting `OffsetExpression`/`LimitExpression`. | After `mxcli test … --local`, an app another `mxcli run --local` is serving goes blank while still answering HTTP 200 (~1.7 KB, the Mendix SPA shell); the runtime log shows `Connector: 404 - file not found for file: dist%2Findex.js` and `deployment/web/dist` is gone | `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go` (`localTestDeployDir`), `cmd/mxcli/testrunner/runner.go` (`checkScratchDeploymentExists`) | A local test run already used its own ports and its own `_test` database — the code comment says why, verbatim — but shared the **deployment directory**, which is the one the *browser* reads. A headless test boot does not bundle the web client, so its build left the running app serving the shell over a 404: tests pass, run keeps running, app is blank, nothing reported at either end. **Detection was not the fix**: the two processes use different ports by design, so no port check can see it, and a lock file would only turn a silent blanking into a refusal. The test boot now builds into `/.mxcli/deployment-test/` — gitignored, already where the test runtime log lives — which makes the collision impossible. Note booting a runtime against the shared directory damages it even **without** a rebuild (the packaging step removes the bundle — FINDINGS §35, `ReportLostWebClientBundle`), so "reuse the dev loop's tree read-only" is not an alternative. Consequence to wire: `--skip-build` used to mean "reuse deployment/" and now has nothing until tests have run once, so it is refused with the reason rather than failing inside the runtime boot against a path the user never chose. Reported as mxcli-formula1 FINDINGS §62 | | A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — "absence from the documentation is not absence from the grammar" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → "widget *pages.X not yet supported by the modelsdk engine"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 | | Every `IMAGE` widget mxcli authors on Mendix 11.13 fails `mx check` with **CE0463** "the definition of this widget has changed", including the exact `pluggablewidget` form an existing bug-test documents as fixed. `mxcli fix widgets` clears it | `mdl/executor/widget_engine.go` (`hiddenUnnamedProperties`, the mapping loop) | **Case B**, established before any hypothesis (diagnose-ce0463 Step 0): the baseline blank project ships **10 Studio Pro-authored widgets of the same widget id** and reports **0** CE0463; one mxcli-authored Image makes it 1. So the tool is the variable — and those 10 are a known-good reference in the same project, better than a template extraction. The exhaustive path diff came back with **every path present on both sides** and four differing values, two of them content; the `Type` (PropertyTypes schema) subtree and the `TypePointer`→`PropertyKey` mapping were identical. **Two hypotheses tested and falsified, which is why they are recorded**: (a) *key order* — mxcli's node is not in the reference's alphabetical order, a documented CE0463 cause, but the same page's mxcli-authored Datagrid and Badge share that order and pass; (b) *the width value* — authoring `Width: 48` explicitly passes, so 48 is not rejected. The cause is the interaction: the widget **hides `width` when `widthUnit` is "auto"**, and a hidden property must hold its DECLARED default. The engine *skipped* the mapping for a hidden property, which leaves the widget **template's captured value** — image.json holds 48 while its own `ValueType.DefaultValue` says 100. mxcli's own **MDL-WIDGET10** already said so verbatim ("a non-default value there fails the build with CE0463 (the default is \"100\")"), so `check` refused what `exec` emitted: the fix makes the writer read `widgetPropertyDefaults`, the checker's own source, so the two cannot disagree again. The engine's comment already stated the invariant correctly — "the hidden ones **at their default**, so hidden means default-valued, not absent" — and skipping only coincides with that when the template happens to be at the default. Where no default can be looked up (a datasource has none) it still skips, so #956's File Uploader pruning is unchanged. **Control**: stub the `SetPrimitive` and CE0463 returns end-to-end. Follow-on the fix *revealed* (CE0463 was masking it): the default `ImageType: image` needs an image-collection entry MDL cannot name, so the bare form builds to Mendix's own "No image selected." — now **MDL-WIDGET22** at check time, and it found the same breakage in four of the repo's own examples. Reported as mxcli-formula1 FINDINGS §69/§142 | +| A `RETRIEVE … WHERE a = $Var/Attr AND b = $Var/Attr` passes `mxcli check` and the build fails **CE0161** "Error(s) in XPath constraint" — but the same statement with **literals** on both sides of the same uppercase `AND` builds fine | `mdl/visitor/xpath_operators.go` (new, `NormalizeXPathOperators`), called from `mdl/visitor/xpath_format.go` | XPath 1.0 spells `and`/`or`/`not` in **lower case only**; MDL's lexer accepts any case (`AND: A N D;`). mxcli lowercased the operator on **one of two rendering paths**: `expressionToXPath` does it while walking the parse tree, but `buildRetrieveWhereExpression` freezes the **raw source** whenever the clause contains a `/` — which every variable path has — and `expressionToXPath`'s `SourceExpr` case hands that text back verbatim. So the casing survived exactly when a path was present, which is why the reporter's literal-only reproducer built cleanly and the report looked not-reproducible. **The correlation was the whole difficulty**: a workaround found under time pressure records what you changed, not what was wrong. Fixed at `FormatXPathConstraint`, the single choke point all three constraint writers share (retrieve, page data source, entity access rule — the latter two measured as broken the same way before the fix), and **before** its width test, because the short branch returns the caller's own bytes. The replacement is token-based and literal-aware for two reasons that are each a worse bug than the one being fixed: rewriting inside `'A AND B'` silently changes which rows match, and an identifier that merely contains the letters (`Brand`, `Andrew`, `NOTES`, `Order_Andon`, `Module.Handover`) is not an operator. `div`/`mod` are deliberately excluded — nothing in MDL emits them, and a rewrite nothing needs can only be wrong. Example `mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl`. Unrelated and pre-existing, found alongside: `$currentUser/...` in a **page** data source constraint is CE0161 regardless of operator case. Reported as mxcli-formula1 FINDINGS §80 | diff --git a/mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl b/mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl new file mode 100644 index 000000000..301c92c16 --- /dev/null +++ b/mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl @@ -0,0 +1,87 @@ +-- mxcli-formula1 FINDINGS §80 — uppercase AND stored verbatim into XPath. +-- +-- ERROR at Formula1Backend, Microflow 'ZZ_AndProbe2', +-- Retrieve object(s) activity 'Retrieve list of LiveForecast from database': +-- Error(s) in XPath constraint. (CE0161) +-- BUILD FAILED +-- +-- XPath 1.0 spells its operators in lower case only; MDL's lexer accepts any +-- case (`AND: A N D;`). mxcli lowercased the operator on one of its two +-- rendering paths and not the other, so the casing survived only in some +-- statements — which is what made the bug look like it was about casing alone. +-- +-- The finding is careful about that, and it is the reason this file has the +-- shape it does. An earlier note blamed the casing, and its literal-only +-- reproducer BUILT CLEANLY: +-- +-- WHERE SessionKey = '1' AND AtLap = 2 -> stored `and`, builds +-- WHERE SessionKey = $N/SessionKey AND AtLap = $N/AtLap -> stored `AND`, CE0161 +-- ...the same with lowercase `and` -> builds +-- +-- The trigger is a VARIABLE reference. `buildRetrieveWhereExpression` freezes +-- the raw source whenever the clause contains a `/`, and every variable path +-- has one; the frozen text is then handed to the writer verbatim. +-- +-- mxcli exec mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl -p app.mpr +-- scripts/mx-check.sh -p app.mpr --version 11.13.0 # no CE0161 +-- +-- Expect every constraint below to be stored with a lower-case `and`. + +create module AndCase; + +create persistent entity AndCase.Forecast ( SessionKey: String(50), AtLap: Integer ); + +-- THE REPORTED CASE: variable references either side of an uppercase AND. +create or modify microflow AndCase.MF_VarAnd ( $Newest: AndCase.Forecast ) +begin + @position(130, 200) + retrieve $L from AndCase.Forecast + where SessionKey = $Newest/SessionKey AND AtLap = $Newest/AtLap; +end; + +-- CONTROL 1 — literals only. This ALWAYS built, because the parse-tree renderer +-- lowercases the operator. Without it the file would not show that the two +-- paths used to disagree. +create or modify microflow AndCase.MF_LiteralAnd () +begin + @position(130, 200) + retrieve $L from AndCase.Forecast where SessionKey = '1' AND AtLap = 2; +end; + +-- CONTROL 2 — the lowercase spelling the reporting project used as a +-- workaround. It built before and must still build. +create or modify microflow AndCase.MF_VarAndLower ( $Newest: AndCase.Forecast ) +begin + @position(130, 200) + retrieve $L from AndCase.Forecast + where SessionKey = $Newest/SessionKey and AtLap = $Newest/AtLap; +end; + +-- OR and NOT are lexed case-insensitively too, and are the same XPath keywords. +create or modify microflow AndCase.MF_OrNot ( $Newest: AndCase.Forecast ) +begin + @position(130, 200) + retrieve $L from AndCase.Forecast + where SessionKey = $Newest/SessionKey OR NOT(AtLap = $Newest/AtLap); +end; + +-- A STRING LITERAL is data, not syntax: the operator inside it must survive +-- untouched, or the constraint quietly matches different rows — worse than the +-- build error being fixed. Only the operator OUTSIDE the quotes is normalised. +create or modify microflow AndCase.MF_LiteralText ( $Newest: AndCase.Forecast ) +begin + @position(130, 200) + retrieve $L from AndCase.Forecast + where SessionKey = 'A AND B' AND AtLap = $Newest/AtLap; +end; + +-- The other two constraint writers go through the same choke point, and were +-- broken in the same way (measured: both stored `AND` before the fix). +create module role AndCase.User; +GRANT AndCase.User ON AndCase.Forecast (READ *) WHERE '[AtLap = 1 AND SessionKey = ''x'']'; + +create or modify page AndCase.P ( Title: 'p', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg (DataSource: DATABASE AndCase.Forecast where '[AtLap = 1 AND SessionKey = ''x'']') { + column c (Attribute: SessionKey) + } +}; diff --git a/mdl/visitor/xpath_format.go b/mdl/visitor/xpath_format.go index daeecda02..6442792ce 100644 --- a/mdl/visitor/xpath_format.go +++ b/mdl/visitor/xpath_format.go @@ -47,6 +47,12 @@ func FormatXPathConstraintWidth(constraint string, width int) string { if strings.TrimSpace(constraint) == "" { return constraint } + // XPath's operator keywords are lower case only, and MDL accepts any case. + // This runs BEFORE the width test, because the short branch returns the + // caller's own bytes and an uppercase `AND` there is CE0161 at build time — + // which is exactly where it hid (mxcli-formula1 §80). See + // NormalizeXPathOperators. + constraint = NormalizeXPathOperators(constraint) // Already short enough to read at a glance: leave it exactly as it is. This // is the case for the overwhelming majority of constraints, and returning the // caller's own bytes is what keeps this change from touching them. diff --git a/mdl/visitor/xpath_operators.go b/mdl/visitor/xpath_operators.go new file mode 100644 index 000000000..5d6630a5d --- /dev/null +++ b/mdl/visitor/xpath_operators.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import "strings" + +// XPath 1.0 spells its operator keywords in LOWER CASE only. MDL's lexer accepts +// any case — `AND: A N D;` — so a constraint written `WHERE a = x AND b = y` is +// perfectly good MDL and, stored verbatim, is XPath mxbuild rejects: +// +// Retrieve object(s) activity 'Retrieve list of LiveForecast from database': +// Error(s) in XPath constraint. (CE0161) +// +// mxcli lowercased the operator already, on one of its two paths. +// expressionToXPath does it while walking the parse tree; but +// buildRetrieveWhereExpression freezes the RAW SOURCE whenever the clause +// contains a `/`, which every variable path does, and expressionToXPath's +// SourceExpr case hands that text straight back. So the casing survived exactly +// when a path was present — which is why the reporting project's literal-only +// reproducer built cleanly and looked like a non-bug (mxcli-formula1 §80). +// +// Normalising here rather than in the retrieve builder covers all three of +// mxcli's constraint writers — retrieve, page data source, entity access rule — +// since all three go through FormatXPathConstraint. + +// xpathOperatorKeywords are the operator names to fold to lower case. Only the +// three MDL itself accepts in any case; `div` and `mod` are XPath keywords too +// but nothing in MDL produces them, and a rewrite nothing needs is a rewrite +// that can only be wrong. +var xpathOperatorKeywords = map[string]string{ + "and": "and", + "or": "or", + "not": "not", +} + +// NormalizeXPathOperators folds XPath's operator keywords to lower case, +// leaving string literals and identifiers exactly as they are. +// +// The two things it must not touch are the reason this scans rather than +// substitutes: +// +// - Inside a STRING LITERAL the letters are data. Rewriting `'A AND B'` +// changes which rows the constraint matches — a silent wrong-answer bug, +// strictly worse than the build error being fixed. +// - An identifier that merely contains the letters (`Brand`, `Andrew`, +// `NOTES`, `Order_Andon`) is not an operator. Only a whole token counts. +func NormalizeXPathOperators(constraint string) string { + if constraint == "" { + return constraint + } + var b strings.Builder + b.Grow(len(constraint)) + + for i := 0; i < len(constraint); { + c := constraint[i] + + // A string literal runs to its closing quote; a doubled quote inside one + // is an escaped quote, not the end (the same rule the MDL expression + // scanner uses). + if c == '\'' { + j := i + 1 + for j < len(constraint) { + if constraint[j] == '\'' { + if j+1 < len(constraint) && constraint[j+1] == '\'' { + j += 2 + continue + } + j++ + break + } + j++ + } + b.WriteString(constraint[i:j]) + i = j + continue + } + + if !isXPathWordByte(c) { + b.WriteByte(c) + i++ + continue + } + + // A whole word. `not` is a function, so it is a keyword whether or not a + // `(` follows; `and`/`or` are infix. Either way the test is the token. + j := i + for j < len(constraint) && isXPathWordByte(constraint[j]) { + j++ + } + word := constraint[i:j] + if lower, ok := xpathOperatorKeywords[strings.ToLower(word)]; ok && !partOfXPathName(constraint, i, j) { + b.WriteString(lower) + } else { + b.WriteString(word) + } + i = j + } + return b.String() +} + +// isXPathWordByte reports whether c can appear inside an XPath name token. +// `.`, `/` and `$` are deliberately excluded: they SEPARATE names, and +// partOfXPathName uses them to tell a qualified name from a bare keyword. +func isXPathWordByte(c byte) bool { + return c == '_' || c == '-' || + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} + +// partOfXPathName reports whether the token at [start,end) is a segment of a +// qualified or navigated name — `Module.Not`, `$v/And`, `And/Name` — where the +// word is somebody's identifier and not an operator. +func partOfXPathName(s string, start, end int) bool { + for i := start - 1; i >= 0; i-- { + switch s[i] { + case ' ', '\t', '\r', '\n': + continue + case '.', '/', '$', '@': + return true + } + break + } + for i := end; i < len(s); i++ { + switch s[i] { + case ' ', '\t', '\r', '\n': + continue + case '.', '/': + return true + } + break + } + return false +} diff --git a/mdl/visitor/xpath_operators_test.go b/mdl/visitor/xpath_operators_test.go new file mode 100644 index 000000000..739d0dd55 --- /dev/null +++ b/mdl/visitor/xpath_operators_test.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import "testing" + +// mxcli-formula1 FINDINGS §80: `RETRIEVE … WHERE a = x AND b = y` stored an +// uppercase `AND` in the XPath constraint, which mxbuild rejects: +// +// ERROR at Formula1Backend, Microflow 'ZZ_AndProbe2', +// Retrieve object(s) activity 'Retrieve list of LiveForecast from database': +// Error(s) in XPath constraint. (CE0161) +// +// XPath 1.0 spells its operators in lower case only, and MDL's lexer accepts +// any case (`AND: A N D;`), so the two have to be reconciled on the way out. +// +// The finding is careful about something worth repeating: an earlier note +// blamed the CASING alone, and a literal-only reproducer built cleanly. The +// trigger is a VARIABLE reference on one side. Measured, and reproduced here on +// 11.13 before the fix: +// +// WHERE SessionKey = '1' AND AtLap = 2 -> stored `and`, builds +// WHERE SessionKey = $N/SessionKey AND AtLap = $N/AtLap -> stored `AND`, CE0161 +// …the same with lowercase `and` -> builds +// +// Two rendering paths, and only one normalises. `expressionToXPath` lowercases +// the operator as it walks the parse tree; but `buildRetrieveWhereExpression` +// freezes the RAW SOURCE whenever the clause contains a `/` — which every +// variable path does — and `expressionToXPath`'s SourceExpr case hands that text +// back verbatim. So the casing only survives when a path is present, which is +// exactly the correlation the finding measured and could not explain. +// +// Normalising at FormatXPathConstraint fixes all three of mxcli's constraint +// writers at once (retrieve, page data source, entity access rule), which is +// why it goes there rather than in the retrieve builder. + +func TestNormalizeXPathOperators_LowercasesBooleanOperators(t *testing.T) { + cases := []struct{ in, want string }{ + // The reported case: a variable path, so the raw source is preserved. + {"[SessionKey = $N/SessionKey AND AtLap = $N/AtLap]", + "[SessionKey = $N/SessionKey and AtLap = $N/AtLap]"}, + {"[a = 1 OR b = 2]", "[a = 1 or b = 2]"}, + {"[NOT(a = 1)]", "[not(a = 1)]"}, + // Mixed case, which the lexer accepts just as readily. + {"[a = 1 And b = 2]", "[a = 1 and b = 2]"}, + {"[a = 1 Or b = 2]", "[a = 1 or b = 2]"}, + // Several in one constraint. + {"[a = 1 AND b = 2 OR c = 3]", "[a = 1 and b = 2 or c = 3]"}, + } + for _, tc := range cases { + if got := NormalizeXPathOperators(tc.in); got != tc.want { + t.Errorf("NormalizeXPathOperators(%q)\n got %q\nwant %q", tc.in, got, tc.want) + } + } +} + +// A STRING LITERAL is data, not syntax. Rewriting inside one changes what the +// constraint matches — a silent wrong-rows bug, which is worse than the CE0161 +// this fixes. +func TestNormalizeXPathOperators_LeavesStringLiteralsAlone(t *testing.T) { + cases := []string{ + "[Name = 'A AND B']", + "[Name = 'AND']", + "[Name = 'NOT SET' AND Active = true()]", + // A doubled quote is an escaped quote inside the literal, not its end. + "[Name = 'it''s AND then' ]", + } + for _, in := range cases { + got := NormalizeXPathOperators(in) + want := in + if in == "[Name = 'NOT SET' AND Active = true()]" { + want = "[Name = 'NOT SET' and Active = true()]" // the operator OUTSIDE is normalised + } + if got != want { + t.Errorf("NormalizeXPathOperators(%q)\n got %q\nwant %q", in, got, want) + } + } +} + +// An identifier that merely CONTAINS an operator's letters is not an operator. +// This is the whole reason the replacement is token-based rather than a +// string substitution. +func TestNormalizeXPathOperators_LeavesIdentifiersAlone(t *testing.T) { + for _, in := range []string{ + "[Brand = 'x']", + "[Andrew = 1]", + "[Module.Handover = 1]", + "[NOTES != empty]", + "[Sales.Order_Andon/Sales.Andon/Name = 'x']", + "[ORDERS = 1]", + } { + if got := NormalizeXPathOperators(in); got != in { + t.Errorf("NormalizeXPathOperators(%q) rewrote an identifier: %q", in, got) + } + } +} + +// Idempotent, and a no-op on what already worked — the literal-only form the +// finding measured as building cleanly must come through untouched. +func TestNormalizeXPathOperators_IsANoOpOnCorrectInput(t *testing.T) { + for _, in := range []string{ + "[SessionKey = '1' and AtLap = 2]", + "[a = 1 or not(b = 2)]", + "", + "[]", + } { + if got := NormalizeXPathOperators(in); got != in { + t.Errorf("NormalizeXPathOperators(%q) = %q, want it unchanged", in, got) + } + } + // Applying it twice changes nothing more. + once := NormalizeXPathOperators("[a = 1 AND b = 2]") + if twice := NormalizeXPathOperators(once); twice != once { + t.Errorf("not idempotent: %q then %q", once, twice) + } +} + +// The end the finding is about: the constraint mxcli actually stores. Every +// constraint writer goes through FormatXPathConstraint, so the normalisation +// has to survive both its branches — the short one that returns the caller's +// own bytes, and the wrapping one. +func TestFormatXPathConstraint_NormalisesTheOperator(t *testing.T) { + short := "[SessionKey = $N/SessionKey AND AtLap = $N/AtLap]" + if got, want := FormatXPathConstraint(short), "[SessionKey = $N/SessionKey and AtLap = $N/AtLap]"; got != want { + t.Errorf("short constraint:\n got %q\nwant %q", got, want) + } + + // Long enough to be wrapped: the operator must be lower case there too, and + // the wrapping (upstream #979) must still happen. + long := "[SessionKey = $Newest/SessionKey AND AtLap = $Newest/AtLap AND Status = 'Published' AND Archived = false()]" + got := FormatXPathConstraint(long) + if containsUpperOperator(got) { + t.Errorf("wrapped constraint kept an uppercase operator:\n%s", got) + } + if !hasNewline(got) { + t.Errorf("a constraint over the width budget should still be wrapped:\n%s", got) + } +} + +func containsUpperOperator(s string) bool { + for _, op := range []string{" AND ", " OR ", "NOT("} { + if idx := indexOf(s, op); idx >= 0 { + return true + } + } + return false +} + +func hasNewline(s string) bool { return indexOf(s, "\n") >= 0 } + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} From 70b0e5af21f8d929d733d89172e674242b26ac1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:26:48 +0000 Subject: [PATCH 04/35] docs(skills): add upgrade-mendix-version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mxcli-formula1 FINDINGS §79 asked for this, having spent most of a session working the upgrade out by hand. Both ways of getting it wrong look like success and neither prints a warning: mxbuild --loose-version-check BUILD SUCCEEDED still on the old version UPDATE _MetaData SET _ProductVersion reports the new every unit read against version the wrong schema §79 proposed it as a skill PACK. It goes in mendix/ instead. The packs README is explicit that packs are opt-in because they are not free — bulk-oql-dml ships MDL that adds Java actions, vega-charts needs a widget installed — and all three existing packs carry mdl/, java/, scripts/ or widget/ trees. This ships nothing and touches no model, so there is nothing to opt into; and §79's own argument for packs/ ("none of it is specific to this solution") is the argument for shipping it to every project, which is what mendix/ does. The inversion is easy to explain: in that project the only two skills installed were packs, so "a skill" looked like "a pack". Everything load-bearing was measured here rather than transcribed, and doing so corrected the source twice. Confirmed, and matching §78 byte for byte across two machines: 11.13.0 {SHA256}5Fk35jOyzj+cWnJe9ZkGWQjMEzsge3nIzS2zxH9jp6M= 11.14.0 {SHA256}o9B9S8lorV9RD5gY9B6j1bJp4ALW87u4newnreIbRAg= Their 517-unit application and a blank app carry identical hashes at the same version, which is what makes _SchemaHash a usable test — and is the thing a single project could not demonstrate. A full 11.13 -> 11.14 `mx convert` on a copy landed exactly on the 11.14 reference value, and `mx check` came back with only the base project's pre-existing error. Two corrections to the source: - §78 verifies MPR v2 survived by counting units and .mxunit sidecars either side. Measured here, .mxunit went 391 -> 386 while v2 was perfectly intact, so an unchanged count is a coincidence rather than the invariant. The skill checks the storage format instead: mprcontents/ still present, .mpr still tens of KB rather than tens of MB. - `mx convert` takes the app DIRECTORY. Given the .mpr it fails with "The app directory '/path/App.mpr' does not exist", which reads like a missing project. Also verified: --loose-version-check leaves _ProductVersion and _SchemaHash untouched; `mx create-project` needs --app-name/--output-dir and fails with PathTooLongException under a deep directory. The commands are the ones that were actually run. The sqlite3 CLI is NOT present in this container, so the skill uses python3's sqlite3 module, and points at mxcli's own connect banner where only the version is wanted. The version-pair specifics are marked as what happened once — the two hashes are facts about two releases, not a pattern, and the skill says to re-derive the reference from a blank project for any version not listed. --- .claude/skills/mendix/README.md | 3 + .../mendix/upgrade-mendix-version/SKILL.md | 178 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 .claude/skills/mendix/upgrade-mendix-version/SKILL.md diff --git a/.claude/skills/mendix/README.md b/.claude/skills/mendix/README.md index 2e0ffd04e..68abbccf2 100644 --- a/.claude/skills/mendix/README.md +++ b/.claude/skills/mendix/README.md @@ -79,6 +79,7 @@ Page-specific patterns: | [migrate-design-prototype](migrate-design-prototype/SKILL.md) | Turn a Claude Design prototype into a themed Mendix app | Reproducing a design handoff/prototype as an SCSS theme + styled pages | | [debug-bson](debug-bson/SKILL.md) | BSON debugging | Troubleshooting SDK issues | | [analyze-runtime](analyze-runtime/SKILL.md) | Analyze runtime behavior — logs, metrics, traces, catalog, and cross-source joins | Profiling a slow page/microflow, finding what hits the DB, correlating cost with model shape | +| [upgrade-mendix-version](upgrade-mendix-version/SKILL.md) | Move a project to a newer Mendix version headlessly | Raising the Mendix version; a build says the project version does not match MxBuild | --- @@ -117,6 +118,8 @@ Load skills based on the task: | "Build/apply a theme from a design" | `migrate-design-prototype`, `theme-styling` | | "Why is this slow / profile the app / what hits the database" | `analyze-runtime`, `run-local` | | "Trace / metrics / flame chart / correlate cost with model" | `analyze-runtime` | +| "Upgrade Mendix version / move to 11.x" | `upgrade-mendix-version` | +| "Project version does not match MxBuild" | `upgrade-mendix-version` | ### For Error Recovery diff --git a/.claude/skills/mendix/upgrade-mendix-version/SKILL.md b/.claude/skills/mendix/upgrade-mendix-version/SKILL.md new file mode 100644 index 000000000..efbef9bdb --- /dev/null +++ b/.claude/skills/mendix/upgrade-mendix-version/SKILL.md @@ -0,0 +1,178 @@ +--- +name: upgrade-mendix-version +description: "Move a project to a newer Mendix version headlessly, with no Studio Pro. Use when raising a project's Mendix version, when a build complains the project version does not match MxBuild, or before adopting a newer runtime. Covers the one check that decides whether a converter must run, and the two green false successes that upgrade nothing." +--- + +# Upgrading a Mendix version without Studio Pro + +## Why this needs a skill + +Both ways of getting it wrong **look like success**: + +| what you do | what you see | what is true | +|---|---|---| +| `mxbuild --loose-version-check` | `BUILD SUCCEEDED` | the project is still on the old version | +| `UPDATE _MetaData SET _ProductVersion` | the project reports the new version, and mxcli agrees | every unit is read against the wrong schema | + +Neither prints a warning. The first is not hypothetical — it is what happened to +the project that reported this, and it took going back to look at the version +column to notice that a successful build had upgraded nothing. + +## The check that decides everything + +A `.mpr` is SQLite. The version lives in one table, `_MetaData`, with columns +`_FormatVersion`, `_ProductVersion`, `_BuildVersion` and `_SchemaHash`. + +Use Python rather than the `sqlite3` CLI: `python3` ships with a `sqlite3` +module, while the CLI is frequently absent (it is not in the mxcli devcontainer). + +```bash +mprhash() { python3 -c "import sqlite3,sys +print(*sqlite3.connect(sys.argv[1]).execute( + 'SELECT _ProductVersion,_SchemaHash FROM _MetaData').fetchone(), sep=' ')" "$1"; } + +mprhash app.mpr +``` + +``` +11.13.0 {SHA256}5Fk35jOyzj+cWnJe9ZkGWQjMEzsge3nIzS2zxH9jp6M= +``` + +For the version alone, mxcli says it on connect — no SQL needed: + +``` +Connected to: /path/App.mpr (Mendix 11.14.0) +``` + +One `UPDATE` would make the project *claim* the new version. Whether that is an +upgrade or a corruption is decided by the **fourth column**: + +1. Create a blank project at the target version (or use a project you already + have there) and read its `_SchemaHash`. +2. Compare it with the project's. + - **Same** → the model schema did not change. The version is a label. + - **Different** → the schema changed, so units stored against the old one + would be read against the new. A converter must run. + +`_SchemaHash` is a property of the **Mendix version, not of the project** — +which is what makes this usable. Measured across two unrelated projects and two +machines: a 517-unit application and a blank app, both at 11.13.0, carry +byte-identical hashes, and the same holds at 11.14.0. So a reference value can be +recorded and reused rather than rebuilt each time. + +``` +11.13.0 {SHA256}5Fk35jOyzj+cWnJe9ZkGWQjMEzsge3nIzS2zxH9jp6M= +11.14.0 {SHA256}o9B9S8lorV9RD5gY9B6j1bJp4ALW87u4newnreIbRAg= +``` + +Two values are not a rule. Treat the table as a cache to check against, and +**re-derive the target version's hash from a blank project the first time you go +to a version that is not listed** — one command, and it is the only thing that +actually answers the question. + +## Doing it + +`mx convert` is the converter. It sits beside `mxbuild` in the toolset, and +mxcli does not wrap it. + +```bash +# 1. Cache the target toolchain. +mxcli setup mxbuild --version 11.14.0 # ~/.mxcli/mxbuild/11.14.0/modeler/ + +# 2. Get the reference hash for the target version, if you do not have it. +mx create-project --app-name Ref --output-dir /short/path +mprhash /short/path/Ref.mpr + +# 3. Convert a COPY first, never the project. +cp -a MyApp /short/MyApp-probe +mx convert --in-place /short/MyApp-probe + +# 4. Verify the copy before touching anything real. +mprhash /short/MyApp-probe/App.mpr # must equal the reference from step 2 +mx check /short/MyApp-probe/App.mpr + +# 5. Only now, the real project. +mx convert --in-place MyApp +``` + +Converting a copy first, confirming its hash matches the reference, and only then +touching the real project is what makes this safe rather than lucky. Step 4 is +the whole point: it is the difference between "the conversion ran" and "the +conversion produced what the target version expects". + +## Verifying MPR v2 survived + +`mx convert` preserves MPR v2 — which is not a given. Its siblings +`mx update-widgets` and `mx rename-design-properties` collapse a v2 project into +a single-file v1 `.mpr` and delete `mprcontents/` as a side effect, one-way. That +is the whole reason `mxcli fix widgets` exists. + +**Check the storage format, not the unit count.** Measured on an 11.13 → 11.14 +conversion: `.mxunit` files went **391 → 386** while v2 was perfectly intact. A +conversion may legitimately drop or merge units, so an unchanged count is a +coincidence, not the invariant. What actually distinguishes preserved from +collapsed: + +```bash +[ -d MyApp/mprcontents ] && echo "v2 intact" # a collapse deletes this +stat -c%s MyApp/App.mpr # v2: tens of KB. v1: tens of MB +``` + +On the measured run: `mprcontents/` present, `.mpr` 73,728 bytes. + +## Traps + +**`mx convert` takes the app DIRECTORY, not the `.mpr`.** Pointing it at the file +fails with a message that reads like the project is missing: + +``` +Conversion failed: The app directory '/path/App.mpr' does not exist. +``` + +**`--loose-version-check` suppresses the check, it does not run a converter.** +Verified: after invoking mxbuild with the flag, `_ProductVersion` and +`_SchemaHash` are untouched. A green build proves nothing about the version. + +**A runtime version and its tooling move together.** The upgrade may break the +tools around it, and the failure need not mention a version at all. On +11.13 → 11.14 it surfaced as: + +``` +Error: bundling web client: no rollup.config.mjs in .../deployment/web + (run a serve Deploy build first) +``` + +— because 11.14's MxBuild bundles the web client itself, so the separate rollup +step has nothing left to configure. Deleting `deployment/` and rebuilding does +not help; a newer mxcli is what fixes it. **When something breaks right after an +upgrade and names no version, suspect the toolchain before the model.** + +**`mx create-project` needs a short output path.** It fails with +`System.IO.PathTooLongException` under a deeply nested directory, during package +extraction — the message names a path length, not the real constraint. + +## What generalises, and what does not + +Most version-pair specifics will be wrong next time. The web-client bundling +change is 11.13 → 11.14 and will not recur; the two hashes above are facts about +two releases, not a pattern. + +What generalises is the decision procedure — **`_SchemaHash` decides label vs +convert** — plus the three traps: convert takes a directory, `--loose-version-check` +upgrades nothing, and the tooling moves with the runtime. Check the specifics +against your own versions rather than trusting them. + +## After the upgrade + +Re-run whatever the project relies on that is not covered by `mx check`, because +a clean check is not evidence the app still works. On the reporting project the +thing worth being anxious about was a non-standard database connection type not +in Mendix's own picker; it survived, and a full sync cycle proved it. Pick your +own equivalent — the integration nobody would notice breaking — and exercise it. + +## Related + +- `run-local` — booting the app after an upgrade; the toolchain mismatch above + surfaces there first. +- `debug-bson` — if the converted model behaves oddly, and for why `mx convert`'s + bare error *count* is not evidence about a model. From 597db1b2514e29fc8f0e1a6a3bd1e5c8d809345b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 07:37:26 +0000 Subject: [PATCH 05/35] feat(pages): point an image widget at an image collection entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MDL could not say WHICH image an image widget shows, so two things went wrong at once (mxcli-formula1 FINDINGS §142). Every mxcli-authored `image` was incomplete. Its default source is an entry from an image collection (`ImageType: image`) and MDL had no way to name one, so the model mxbuild got was "No image selected.". And a describe -> rename -> exec copy of an Atlas layout came out with no brand image: describe emitted `image staticImage1 (Responsive: false)` because the reference was in the stored document with no MDL spelling. image imgLogo (Image: 'MyFirstModule.Images._1') The name is three parts like an icon reference, Module.Collection.Image, stored as a plain string on the WidgetValue's `Image` key. Both halves are needed: with only the write, a round trip still loses it. Write: `SetImage` on the shared widgetobj builder (both engines) plus the mcp builder, an `image` operation in the widget engine, and `imageObject` mapped in both embedded image defs. `operationForType` learns the type, so a generated def picks up image-typed properties on any widget. `setImageValue` REPLACES the existing key and never adds one - adding a key the definition does not declare is the CE0463 shape. Read: `extractCustomWidgetPropertyImage` resolves the property through its TypePointer (the Image widget has two image-typed properties, so a positional read returns the wrong one), and describe emits `Image:`. MDL-WIDGET22 now reports a genuinely incomplete widget rather than a missing capability, and leads with the remedy that keeps the author's intent instead of telling them to switch source. Verified on 11.13.0: the page builds at 0 errors (base project's CE0117 aside), and `describe layout Atlas_Core.Atlas_Default` now emits `image staticImage1 (Image: 'Atlas_Core.Layout.logo', Responsive: false)` - the copy keeps its logo and builds clean. Controls: stub `setImageValue` and TestSetImageValue_SetsTheQualifiedName fails; stub the describe emission and TestDescribeImageWidget_EmitsTheImageReference fails. --- .claude/skills/fix-issue.md | 1 + .claude/skills/mendix/create-page/SKILL.md | 27 ++++- cmd/mxcli/syntax/features_page.go | 12 +- docs/01-project/MDL_QUICK_REFERENCE.md | 8 ++ .../widget-image-collection-entry.mdl | 64 ++++++++++ mdl/backend/mcp/widget.go | 7 ++ mdl/backend/mutation.go | 4 + mdl/backend/widgetobj/builder.go | 39 ++++++ mdl/backend/widgetobj/builder_image_test.go | 104 ++++++++++++++++ mdl/executor/cmd_pages_describe.go | 7 +- mdl/executor/cmd_pages_describe_image_test.go | 113 ++++++++++++++++++ mdl/executor/cmd_pages_describe_output.go | 84 +++++++------ mdl/executor/cmd_pages_describe_pluggable.go | 34 ++++++ mdl/executor/validate_widget_image.go | 45 +++---- mdl/executor/validate_widget_image_test.go | 42 ++++++- mdl/executor/widget_defs.go | 2 + mdl/executor/widget_engine.go | 5 + mdl/executor/widget_registry.go | 4 + modelsdk/widgets/definitions/image.def.json | 5 + sdk/widgets/definitions/image.def.json | 5 + 20 files changed, 546 insertions(+), 66 deletions(-) create mode 100644 mdl-examples/bug-tests/widget-image-collection-entry.mdl create mode 100644 mdl/backend/widgetobj/builder_image_test.go create mode 100644 mdl/executor/cmd_pages_describe_image_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 8d925e5ef..1a5846af2 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -760,3 +760,4 @@ extracting `OffsetExpression`/`LimitExpression`. | A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — "absence from the documentation is not absence from the grammar" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → "widget *pages.X not yet supported by the modelsdk engine"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 | | Every `IMAGE` widget mxcli authors on Mendix 11.13 fails `mx check` with **CE0463** "the definition of this widget has changed", including the exact `pluggablewidget` form an existing bug-test documents as fixed. `mxcli fix widgets` clears it | `mdl/executor/widget_engine.go` (`hiddenUnnamedProperties`, the mapping loop) | **Case B**, established before any hypothesis (diagnose-ce0463 Step 0): the baseline blank project ships **10 Studio Pro-authored widgets of the same widget id** and reports **0** CE0463; one mxcli-authored Image makes it 1. So the tool is the variable — and those 10 are a known-good reference in the same project, better than a template extraction. The exhaustive path diff came back with **every path present on both sides** and four differing values, two of them content; the `Type` (PropertyTypes schema) subtree and the `TypePointer`→`PropertyKey` mapping were identical. **Two hypotheses tested and falsified, which is why they are recorded**: (a) *key order* — mxcli's node is not in the reference's alphabetical order, a documented CE0463 cause, but the same page's mxcli-authored Datagrid and Badge share that order and pass; (b) *the width value* — authoring `Width: 48` explicitly passes, so 48 is not rejected. The cause is the interaction: the widget **hides `width` when `widthUnit` is "auto"**, and a hidden property must hold its DECLARED default. The engine *skipped* the mapping for a hidden property, which leaves the widget **template's captured value** — image.json holds 48 while its own `ValueType.DefaultValue` says 100. mxcli's own **MDL-WIDGET10** already said so verbatim ("a non-default value there fails the build with CE0463 (the default is \"100\")"), so `check` refused what `exec` emitted: the fix makes the writer read `widgetPropertyDefaults`, the checker's own source, so the two cannot disagree again. The engine's comment already stated the invariant correctly — "the hidden ones **at their default**, so hidden means default-valued, not absent" — and skipping only coincides with that when the template happens to be at the default. Where no default can be looked up (a datasource has none) it still skips, so #956's File Uploader pruning is unchanged. **Control**: stub the `SetPrimitive` and CE0463 returns end-to-end. Follow-on the fix *revealed* (CE0463 was masking it): the default `ImageType: image` needs an image-collection entry MDL cannot name, so the bare form builds to Mendix's own "No image selected." — now **MDL-WIDGET22** at check time, and it found the same breakage in four of the repo's own examples. Reported as mxcli-formula1 FINDINGS §69/§142 | | A `RETRIEVE … WHERE a = $Var/Attr AND b = $Var/Attr` passes `mxcli check` and the build fails **CE0161** "Error(s) in XPath constraint" — but the same statement with **literals** on both sides of the same uppercase `AND` builds fine | `mdl/visitor/xpath_operators.go` (new, `NormalizeXPathOperators`), called from `mdl/visitor/xpath_format.go` | XPath 1.0 spells `and`/`or`/`not` in **lower case only**; MDL's lexer accepts any case (`AND: A N D;`). mxcli lowercased the operator on **one of two rendering paths**: `expressionToXPath` does it while walking the parse tree, but `buildRetrieveWhereExpression` freezes the **raw source** whenever the clause contains a `/` — which every variable path has — and `expressionToXPath`'s `SourceExpr` case hands that text back verbatim. So the casing survived exactly when a path was present, which is why the reporter's literal-only reproducer built cleanly and the report looked not-reproducible. **The correlation was the whole difficulty**: a workaround found under time pressure records what you changed, not what was wrong. Fixed at `FormatXPathConstraint`, the single choke point all three constraint writers share (retrieve, page data source, entity access rule — the latter two measured as broken the same way before the fix), and **before** its width test, because the short branch returns the caller's own bytes. The replacement is token-based and literal-aware for two reasons that are each a worse bug than the one being fixed: rewriting inside `'A AND B'` silently changes which rows match, and an identifier that merely contains the letters (`Brand`, `Andrew`, `NOTES`, `Order_Andon`, `Module.Handover`) is not an operator. `div`/`mod` are deliberately excluded — nothing in MDL emits them, and a rewrite nothing needs can only be wrong. Example `mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl`. Unrelated and pre-existing, found alongside: `$currentUser/...` in a **page** data source constraint is CE0161 regardless of operator case. Reported as mxcli-formula1 FINDINGS §80 | +| An `image` widget mxcli wrote shows nothing, and `mx check` fails with **"No image selected."**; and a `describe` → rename → `exec` copy of an Atlas layout (or any page with a brand image) comes out with the image gone, describe having emitted `image staticImage1 (Responsive: false)` with no reference at all | `mdl/executor/widget_engine.go` (the `image` operation), `mdl/executor/widget_defs.go` + `mdl/executor/widget_registry.go` (`operationForType`, `defaultKnownOperations`), `mdl/backend/widgetobj/builder.go` (`SetImage`, `setImageValue`), `mdl/backend/mutation.go` + `mdl/backend/mcp/widget.go`, `{modelsdk,sdk}/widgets/definitions/image.def.json` (`imageObject` → `Image`), `mdl/executor/cmd_pages_describe_pluggable.go` (`extractCustomWidgetPropertyImage`) + `cmd_pages_describe_output.go` (`describeImageWidgetProps`) | MDL had no spelling for **which** image an image widget shows, so its default source (`ImageType: image`, an image collection entry) could never be satisfied and describe had nothing to emit — mxcli-formula1 FINDINGS §142. The name is three parts like an icon reference, `Module.Collection.Image`, stored as a plain string on the `WidgetValue`'s `Image` key. Both halves are needed: with only the write, a round trip still loses it. `setImageValue` **replaces** the existing `Image` key and never adds one — adding a key the widget definition does not declare is the CE0463 shape. Controls: stub `setImageValue` → `TestSetImageValue_SetsTheQualifiedName` fails; stub the describe emission → `TestDescribeImageWidget_EmitsTheImageReference` fails | diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index c84df99aa..a9a839d18 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -355,6 +355,31 @@ The following features are NOT implemented in mxcli and require manual configura > DYNAMICTEXT spacer (Content: ' ') > ``` +### IMAGE needs a source + +An `image` widget shows an entry from an **image collection** by default, and the +entry is named as three parts — `Module.Collection.ImageName`: + +```sql +image imgLogo (Image: 'MyFirstModule.Images._1', Width: 48, Height: 48) +``` + +`show image collections` lists the collections; `describe image collection +MyFirstModule.Images` lists the images inside one. + +An `image` with that (default) source and no `Image:` builds into a model mxbuild +refuses — *"No image selected."* — so `mxcli check` reports it as **MDL-WIDGET22** +before you spend a build on it. A name that does not resolve is reported by +`mxcli check --references`, which is cheaper than mxbuild's CE1613 *"The selected +image … no longer exists."* + +The two other sources take no collection entry: + +```sql +image imgRemote (ImageType: imageUrl, ImageUrl: 'https://example.com/logo.svg') +image imgIcon (ImageType: icon) +``` + ### Binding across modules and to audit members An attribute path may cross module boundaries, including into the platform's @@ -427,7 +452,7 @@ All shorthand widgets (IMAGE, COMBOBOX, GALLERY, DATAGRID, etc.) are pluggable w ```sql -- Shorthand (common properties only) -image imgLogo (width: 48, height: 48) +image imgLogo (Image: 'MyFirstModule.Images._1', width: 48, height: 48) -- Full PLUGGABLEWIDGET syntax (all properties available) pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index cabbb3e2e..35cdd470d 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -50,11 +50,15 @@ func init() { "-- Data grid filters and sort (inside a DATAGRID's FILTER block)\nDATAGRID dg (...) { COLUMN c (Attribute: A) FILTER f { TEXTFILTER tf (Attribute: A) } }\nTEXTFILTER | NUMBERFILTER | DATEFILTER | DROPDOWNFILTER | DROPDOWNSORT\n\n" + "-- Inputs\nTEXTBOX name (Label: 'L', Attribute: Attr)\nTEXTAREA | DATEPICKER | COMBOBOX | CHECKBOX | RADIOBUTTONS\n\n" + "-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE_CHANGES, ButtonStyle: Primary)\nLINKBUTTON name (Caption: 'C', Action: ...)\n\n" + - "-- Display\nDYNAMICTEXT name (Content: 'Hello, {1}!', ContentParams: [{1} = Name])\nTITLE name (Content: 'Heading')\nIMAGE name (ImageType: imageUrl, ImageUrl: 'https://…')\n" + + "-- Display\nDYNAMICTEXT name (Content: 'Hello, {1}!', ContentParams: [{1} = Name])\nTITLE name (Content: 'Heading')\nIMAGE name (Image: 'Module.Collection.ImageName')\nIMAGE name (ImageType: imageUrl, ImageUrl: 'https://…')\n" + "-- IMAGE needs a source. Its default, `ImageType: image`, shows an entry from an\n" + - "-- image collection — which MDL cannot yet name, so the bare form writes a model\n" + - "-- mxbuild refuses (\"No image selected.\"). MDL-WIDGET22 reports that at check\n" + - "-- time. Use the URL form above, or `ImageType: icon`.\n\n" + + "-- image collection, named as three parts: Module.Collection.ImageName.\n" + + "-- `show image collections` lists the collections, `describe image collection\n" + + "-- Module.Collection` the images inside one. An IMAGE with that source and no\n" + + "-- entry writes a model mxbuild refuses (\"No image selected.\"); MDL-WIDGET22\n" + + "-- reports that at check time, and a name that does not resolve is reported by\n" + + "-- `check --references` rather than failing the build with CE1613.\n" + + "-- The alternatives are the URL form above, or `ImageType: icon`.\n\n" + "-- Any pluggable widget by its id (id FIRST, then the name)\nPLUGGABLEWIDGET 'com.mendix.widget.web.badge.Badge' name (value: 'x')\nCUSTOMWIDGET 'com.mendix.widget.custom.x.X' name (prop: 'x') -- legacy spelling\n\n" + "-- Accepted by the parser, NOT writable on the default engine.\n" + "-- Measured on 11.13.0: each is refused with\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index ce9b8a2b4..5f08f95a4 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -906,6 +906,14 @@ Respond in {{Language}}.$$, | Create collection | `create image collection Module.Name [folder 'path'] [export level 'Hidden'\|'Public'] [comment 'text'] [(image Name from file 'path', ...)];` | With or without images | | Create or modify | `create or modify image collection Module.Name [...];` | Preserves UUID — preferred for AI agents | | Drop collection | `drop image collection Module.Name;` | Removes collection and all embedded images | +| Show an image on a page | `image imgLogo (Image: 'Module.Collection.ImageName');` | Three-part name, like an icon reference. `describe image collection` lists the names | + +An `image` widget's default source **is** an image collection entry, so a bare +`image imgLogo (...)` with no `Image:` builds into a model mxbuild refuses ("No +image selected."); `mxcli check` reports it as MDL-WIDGET22. A name that does not +resolve is reported by `mxcli check --references` rather than by the build +(CE1613). The other two sources are `ImageType: imageUrl, ImageUrl: '…'` and +`ImageType: icon`. ## Icon Collections (read-only) diff --git a/mdl-examples/bug-tests/widget-image-collection-entry.mdl b/mdl-examples/bug-tests/widget-image-collection-entry.mdl new file mode 100644 index 000000000..d5ca2aefa --- /dev/null +++ b/mdl-examples/bug-tests/widget-image-collection-entry.mdl @@ -0,0 +1,64 @@ +-- mxcli-formula1 FINDINGS §142 — MDL could not say WHICH image an image widget +-- shows, so two things went wrong at once. +-- +-- 1. Every mxcli-authored `image` widget was incomplete. Its default source is +-- an entry from an image collection (`ImageType: image`), and MDL had no way +-- to name one, so the model mxbuild got was: +-- +-- [error] "No image selected." at Image 'imgLogo' +-- +-- 2. A describe -> rename -> exec copy of an Atlas layout came out with no +-- brand image. `describe` emitted the widget as +-- +-- image staticImage1 (Responsive: false) +-- +-- because there was nothing to emit — the reference was in the stored +-- document and had no MDL spelling. +-- +-- Both halves are needed: the WRITE (the `image` operation on the widget +-- engine, mapping the widget's `imageObject` property) and the READ (describe +-- emitting `Image:`). With only the write, a round trip still loses it. +-- +-- The name has THREE parts, like an icon reference: Module.Collection.Image. +-- +-- Verify: +-- mxcli exec widget-image-collection-entry.mdl -p app.mpr +-- mxcli -p app.mpr -c "DESCRIBE PAGE IMG.ImagePage" +-- -- must emit: image imgLogo (Image: 'MyFirstModule.Images._1') +-- mx check -p app.mpr -- 0 errors; before the fix, "No image selected." +-- +-- Adjust the image reference to one this project actually has: +-- mxcli -p app.mpr -c "show image collections" +-- mxcli -p app.mpr -c "describe image collection MyFirstModule.Images" + +create module IMG; + +create or replace page IMG.ImagePage ( + title: 'Image sources', + layout: Atlas_Core.Atlas_Default +) { + -- The default source: an entry from an image collection. + image imgLogo (Image: 'MyFirstModule.Images._1') + + -- The two sources that take no collection entry, unchanged by this fix. + image imgRemote (ImageType: imageUrl, ImageUrl: 'https://example.com/logo.svg') + image imgIcon (ImageType: icon) +} + +-- Both new checks, as MDL that must FAIL `mxcli check`. Uncomment one at a time. +-- +-- MDL-WIDGET22 — the default source with no entry ("No image selected."): +-- +-- create or replace page IMG.NoSource (title: 'x', layout: Atlas_Core.Atlas_Default) { +-- image imgNothing (Width: 48, Height: 48) +-- } +-- +-- check --references — a name that does not resolve. Before the reference check +-- this passed `mxcli check --references` and failed the BUILD with +-- [CE1613] "The selected image '...' no longer exists." +-- +-- create or replace page IMG.BadRef (title: 'x', layout: Atlas_Core.Atlas_Default) { +-- image imgTypo (Image: 'MyFirstModule.Images.NoSuchImage') -- image not found +-- image imgColl (Image: 'Nope.Missing.Img') -- collection not found +-- image imgShape (Image: 'JustTwo.Parts') -- not three parts +-- } diff --git a/mdl/backend/mcp/widget.go b/mdl/backend/mcp/widget.go index f7a2b0255..5f6bc8ca7 100644 --- a/mdl/backend/mcp/widget.go +++ b/mdl/backend/mcp/widget.go @@ -183,6 +183,13 @@ func (w *mcpWidgetBuilder) SetPrimitive(propertyKey, value string) { w.object[propertyKey] = value } +func (w *mcpWidgetBuilder) SetImage(propertyKey, imageQN string) { + if imageQN == "" { + return + } + w.object[propertyKey] = imageQN +} + func (w *mcpWidgetBuilder) SetDataSource(propertyKey string, ds pages.DataSource) { if src := customWidgetXPathSource(ds); src != nil { w.object[propertyKey] = src diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 6a93b0d93..6f49aed60 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -336,6 +336,10 @@ type WidgetObjectBuilder interface { SetAssociation(propertyKey string, assocPath string, entityName string) SetPrimitive(propertyKey string, value string) SetSelection(propertyKey string, value string) + // SetImage points an image-typed property (e.g. the Image widget's + // `imageObject`) at an image collection entry, by its three-part qualified + // name `Module.Collection.Image`. + SetImage(propertyKey string, imageQN string) SetExpression(propertyKey string, value string) SetDataSource(propertyKey string, ds pages.DataSource) SetChildWidgets(propertyKey string, children []pages.Widget) diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index 239bd8a3b..f01c85209 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -95,6 +95,23 @@ func (ob *Builder) SetPrimitive(propertyKey string, value string) { }) } +// SetImage points an image-typed property at an image collection entry, by its +// three-part qualified name (`Module.Collection.Image`). +// +// The Image widget's `imageObject` is the case this exists for: it holds the +// image the widget shows, and without it the default `ImageType: image` writes a +// model mxbuild refuses with "No image selected." It is also what a describe → +// exec copy of an Atlas layout needs in order to keep its brand image +// (mxcli-formula1 FINDINGS §142). +func (ob *Builder) SetImage(propertyKey string, imageQN string) { + if imageQN == "" { + return + } + ob.object = updateWidgetPropertyValue(ob.object, ob.propertyTypeIDs, propertyKey, func(val bson.D) bson.D { + return setImageValue(val, imageQN) + }) +} + func (ob *Builder) SetSelection(propertyKey string, value string) { if value == "" { return @@ -1080,6 +1097,28 @@ func setPrimitiveValue(val bson.D, value string) bson.D { return result } +// setImageValue writes the image reference onto a WidgetValue's `Image` key. +// +// The key is REPLACED, never added: a value node that does not declare one +// belongs to a property that is not image-typed, and a key the widget's +// definition does not know about is the CE0463 shape. Measured on a Studio +// Pro-authored widget, the stored form is the bare qualified name — +// `MyFirstModule.Images._1` — with nothing else on the node changed. +func setImageValue(val bson.D, imageQN string) bson.D { + if imageQN == "" { + return val + } + result := make(bson.D, 0, len(val)) + for _, elem := range val { + if elem.Key == "Image" { + result = append(result, bson.E{Key: "Image", Value: imageQN}) + } else { + result = append(result, elem) + } + } + return result +} + func setDataSource(val bson.D, ds pages.DataSource) bson.D { result := make(bson.D, 0, len(val)) for _, elem := range val { diff --git a/mdl/backend/widgetobj/builder_image_test.go b/mdl/backend/widgetobj/builder_image_test.go new file mode 100644 index 000000000..1f0e273ee --- /dev/null +++ b/mdl/backend/widgetobj/builder_image_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package widgetobj + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +// mxcli-formula1 FINDINGS §142, and the gap MDL-WIDGET22 was named for: MDL +// could not say WHICH image an image widget shows. +// +// The Image widget's `datasource` defaults to `image` — an entry from an image +// collection — and the property holding that entry, `imageObject`, is of type +// `image`. The widget engine had no operation for that type, so the property was +// unreachable from MDL and every default-form image widget wrote a model mxbuild +// refuses with "No image selected." It is also why a describe → exec copy of an +// Atlas layout loses its brand image. +// +// The stored shape is a plain string on the WidgetValue, measured on a Studio +// Pro-authored widget in a stock project: +// +// /Object/Properties[2]/Value/Image = "MyFirstModule.Images._1" +// +// Three parts: Module.Collection.Image. Nothing else about the value node +// changes, which is why this is the same shape as SetSelection rather than +// anything structural. + +// imageValue is one WidgetValue as stored: the Image key sits beside the others +// and is empty until something sets it. +func imageValue() bson.D { + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "PrimitiveValue", Value: ""}, + {Key: "Image", Value: ""}, + {Key: "TextTemplate", Value: nil}, + } +} + +func get(d bson.D, key string) any { + for _, e := range d { + if e.Key == key { + return e.Value + } + } + return nil +} + +func TestSetImageValue_SetsTheQualifiedName(t *testing.T) { + got := setImageValue(imageValue(), "MyFirstModule.Images._1") + + if v := get(got, "Image"); v != "MyFirstModule.Images._1" { + t.Errorf("Image = %v, want the qualified name", v) + } +} + +// Every other key must come through untouched and in order. A WidgetValue that +// gained or lost a key is the CE0463 shape this whole area keeps producing. +func TestSetImageValue_LeavesEverythingElseAlone(t *testing.T) { + in := imageValue() + got := setImageValue(in, "Mod.Coll.Img") + + if len(got) != len(in) { + t.Fatalf("key count changed: %d -> %d", len(in), len(got)) + } + for i := range in { + if got[i].Key != in[i].Key { + t.Errorf("key %d: %q -> %q — order changed", i, in[i].Key, got[i].Key) + } + } + if v := get(got, "PrimitiveValue"); v != "" { + t.Errorf("PrimitiveValue was disturbed: %v", v) + } + if v := get(got, "TextTemplate"); v != nil { + t.Errorf("TextTemplate was disturbed: %v", v) + } +} + +// A value node with no Image key at all is left as it is rather than having one +// invented. Adding a key the widget's definition does not declare is precisely +// what mxbuild answers with CE0463. +func TestSetImageValue_DoesNotInventTheKey(t *testing.T) { + in := bson.D{{Key: "$Type", Value: "CustomWidgets$WidgetValue"}, {Key: "PrimitiveValue", Value: ""}} + got := setImageValue(in, "Mod.Coll.Img") + + if len(got) != len(in) { + t.Fatalf("a key was added to a value node that does not declare one: %v", got) + } +} + +// An empty name is a no-op, like every other Set* on this builder: "the script +// did not say" must not overwrite what the template holds. Without this, an +// image widget authored without `Image:` would have its template default +// blanked rather than left alone. +func TestSetImageValue_EmptyNameLeavesTheValueAlone(t *testing.T) { + in := bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "Image", Value: "Mod.Coll.Existing"}, + } + if got := get(setImageValue(in, ""), "Image"); got != "Mod.Coll.Existing" { + t.Errorf("Image = %v, want the existing value untouched", got) + } +} diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index b211a157c..21e00be47 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -689,7 +689,12 @@ type rawWidget struct { DisplayAs string // "fullImage", "thumbnail" Responsive string // "true", "false" ImageType string // "image", "imageUrl", "icon" - OnClickType string // "action", "enlarge" + // ImageObject is the image collection entry the widget shows, as the + // three-part qualified name Module.Collection.Image. Empty when the source + // is not an image collection, or when none is selected. Without it a + // describe -> exec copy loses the image (mxcli-formula1 FINDINGS §142). + ImageObject string + OnClickType string // "action", "enlarge" } // rawExplicitProp represents a non-default property extracted from a CustomWidget. diff --git a/mdl/executor/cmd_pages_describe_image_test.go b/mdl/executor/cmd_pages_describe_image_test.go new file mode 100644 index 000000000..a25d096c1 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_image_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// mxcli-formula1 FINDINGS §142: a describe → rename → exec copy of an Atlas +// layout comes out with no brand image. +// +// `describe` emitted the Image widget as the built-in shorthand with no image +// reference at all — +// +// image staticImage1 (Responsive: false) +// +// — because MDL had no way to name an image collection entry, so there was +// nothing to emit. Wiring the `image` operation gives the writer that +// capability; without the READ half the round trip still loses it, and the copy +// still renders blank. +// +// The stored form is a plain string on the WidgetValue, measured on a Studio +// Pro-authored widget: `Image: "MyFirstModule.Images._1"`, three parts, +// Module.Collection.Image. + +// imagePropWidget is one stored CustomWidget carrying an image-typed property. +// The TypePointer indirection is the whole reason a dedicated extractor exists: +// the property is found by resolving its pointer to a PropertyKey, not by +// position. +func imagePropWidget(propertyKey, stored string) map[string]any { + return map[string]any{ + "Type": map[string]any{ + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{ + "$ID": "pt-1", + "$Type": "CustomWidgets$WidgetPropertyType", + "PropertyKey": propertyKey, + }, + }, + }, + }, + "Object": map[string]any{ + "Properties": []any{ + map[string]any{ + "$Type": "CustomWidgets$WidgetProperty", + "TypePointer": "pt-1", + "Value": map[string]any{ + "$Type": "CustomWidgets$WidgetValue", + "PrimitiveValue": "", + "Image": stored, + }, + }, + }, + }, + } +} + +func TestExtractCustomWidgetPropertyImage_ReadsTheReference(t *testing.T) { + ctx, _ := newMockCtx(t) + got := extractCustomWidgetPropertyImage(ctx, imagePropWidget("imageObject", "MyFirstModule.Images._1"), "imageObject") + if got != "MyFirstModule.Images._1" { + t.Errorf("got %q, want the stored qualified name", got) + } +} + +// CONTROL 1: an unset image reads as empty, so DESCRIBE emits nothing rather +// than an `Image: ”` that would not re-execute. +func TestExtractCustomWidgetPropertyImage_UnsetIsEmpty(t *testing.T) { + ctx, _ := newMockCtx(t) + if got := extractCustomWidgetPropertyImage(ctx, imagePropWidget("imageObject", ""), "imageObject"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// CONTROL 2: it must resolve the property by KEY, not take the first Image it +// finds. The Image widget has two image-typed properties — `imageObject` and +// `defaultImageDynamic` — so a positional read would return the wrong one. +func TestExtractCustomWidgetPropertyImage_ResolvesByPropertyKey(t *testing.T) { + ctx, _ := newMockCtx(t) + w := imagePropWidget("defaultImageDynamic", "Mod.Coll.Fallback") + if got := extractCustomWidgetPropertyImage(ctx, w, "imageObject"); got != "" { + t.Errorf("got %q for imageObject, but only defaultImageDynamic is set", got) + } + if got := extractCustomWidgetPropertyImage(ctx, w, "defaultImageDynamic"); got != "Mod.Coll.Fallback" { + t.Errorf("got %q, want the fallback image", got) + } +} + +// The end §142 is about: what DESCRIBE prints. An image widget with an entry +// must emit `Image:` so describe → exec keeps it. +func TestDescribeImageWidget_EmitsTheImageReference(t *testing.T) { + w := rawWidget{Name: "imgLogo"} + w.ImageObject = "MyFirstModule.Images._1" + + out := describeImageWidgetProps(w) + if !strings.Contains(strings.Join(out, ", "), "Image: 'MyFirstModule.Images._1'") { + t.Errorf("describe did not emit the image reference: %v", out) + } +} + +// CONTROL: a widget with no entry emits no `Image:` at all. An empty one would +// re-execute into a reference to nothing. +func TestDescribeImageWidget_OmitsAnAbsentReference(t *testing.T) { + w := rawWidget{Name: "imgLogo"} + + for _, p := range describeImageWidgetProps(w) { + if strings.HasPrefix(p, "Image:") { + t.Errorf("emitted %q for a widget with no image", p) + } + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index d164a4747..f96cab77b 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -653,40 +653,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { } } else if widgetType == "image" { header := fmt.Sprintf("image %s", mdlIdent(w.Name)) - props := []string{} - if w.ImageType != "" && w.ImageType != "image" { - props = append(props, fmt.Sprintf("ImageType: %s", w.ImageType)) - } - if w.ImageUrl != "" { - props = append(props, fmt.Sprintf("ImageUrl: %s", mdlQuote(w.ImageUrl))) - } - if w.AlternativeText != "" { - props = append(props, fmt.Sprintf("AlternativeText: %s", mdlQuote(w.AlternativeText))) - } - if w.WidthUnit != "" && w.WidthUnit != "auto" { - props = append(props, fmt.Sprintf("WidthUnit: %s", w.WidthUnit)) - } - if w.ImageWidth != "" && w.ImageWidth != "100" { - props = append(props, fmt.Sprintf("Width: %s", w.ImageWidth)) - } - if w.HeightUnit != "" && w.HeightUnit != "auto" { - props = append(props, fmt.Sprintf("HeightUnit: %s", w.HeightUnit)) - } - if w.ImageHeight != "" && w.ImageHeight != "100" { - props = append(props, fmt.Sprintf("Height: %s", w.ImageHeight)) - } - if w.DisplayAs != "" && w.DisplayAs != "fullImage" { - props = append(props, fmt.Sprintf("DisplayAs: %s", w.DisplayAs)) - } - if w.Responsive != "" && w.Responsive != "true" { - props = append(props, fmt.Sprintf("Responsive: %s", w.Responsive)) - } - if w.OnClickType == "enlarge" { - props = append(props, "OnClickType: enlarge") - } - if w.Action != "" { - props = append(props, fmt.Sprintf("OnClick: %s", w.Action)) - } + props := describeImageWidgetProps(w) props = appendConditionalProps(props, w) props = appendAppearanceProps(props, w) formatWidgetProps(ctx.Output, prefix, header, props, "\n") @@ -1706,3 +1673,52 @@ func appendNamedActionProps(props []string, w rawWidget) []string { } return props } + +// describeImageWidgetProps renders an image widget's own properties, separated +// from the output loop so the round trip can be asserted directly. +// +// `Image:` is the one §142 turns on: it names the image collection entry the +// widget shows, and until the `image` operation existed there was nothing to +// emit, so describe -> exec copied an Atlas layout and lost its brand image. An +// ABSENT entry emits nothing rather than an empty `Image: ”`, which would +// re-execute into a reference to nothing. +func describeImageWidgetProps(w rawWidget) []string { + props := []string{} + if w.ImageType != "" && w.ImageType != "image" { + props = append(props, fmt.Sprintf("ImageType: %s", w.ImageType)) + } + if w.ImageObject != "" { + props = append(props, fmt.Sprintf("Image: %s", mdlQuote(w.ImageObject))) + } + if w.ImageUrl != "" { + props = append(props, fmt.Sprintf("ImageUrl: %s", mdlQuote(w.ImageUrl))) + } + if w.AlternativeText != "" { + props = append(props, fmt.Sprintf("AlternativeText: %s", mdlQuote(w.AlternativeText))) + } + if w.WidthUnit != "" && w.WidthUnit != "auto" { + props = append(props, fmt.Sprintf("WidthUnit: %s", w.WidthUnit)) + } + if w.ImageWidth != "" && w.ImageWidth != "100" { + props = append(props, fmt.Sprintf("Width: %s", w.ImageWidth)) + } + if w.HeightUnit != "" && w.HeightUnit != "auto" { + props = append(props, fmt.Sprintf("HeightUnit: %s", w.HeightUnit)) + } + if w.ImageHeight != "" && w.ImageHeight != "100" { + props = append(props, fmt.Sprintf("Height: %s", w.ImageHeight)) + } + if w.DisplayAs != "" && w.DisplayAs != "fullImage" { + props = append(props, fmt.Sprintf("DisplayAs: %s", w.DisplayAs)) + } + if w.Responsive != "" && w.Responsive != "true" { + props = append(props, fmt.Sprintf("Responsive: %s", w.Responsive)) + } + if w.OnClickType == "enlarge" { + props = append(props, "OnClickType: enlarge") + } + if w.Action != "" { + props = append(props, fmt.Sprintf("OnClick: %s", w.Action)) + } + return props +} diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index ace68219a..28add25ef 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -941,6 +941,39 @@ func extractCustomWidgetPropertyAssociation(ctx *ExecContext, w map[string]any, } // extractCustomWidgetPropertyString extracts a string property value from a CustomWidget. +// extractCustomWidgetPropertyImage reads an image-typed property's stored +// reference — the WidgetValue's `Image` key, holding a three-part +// Module.Collection.Image qualified name. +// +// It resolves the property through its TypePointer like every other extractor +// here, rather than by position: the Image widget has TWO image-typed +// properties (`imageObject` and `defaultImageDynamic`), so taking the first +// Image found would return the wrong one. +func extractCustomWidgetPropertyImage(ctx *ExecContext, w map[string]any, propertyKey string) string { + obj, ok := w["Object"].(map[string]any) + if !ok { + return "" + } + propTypeKeyMap := buildPropertyTypeKeyMap(w, false) + for _, prop := range getBsonArrayElements(obj["Properties"]) { + propMap, ok := prop.(map[string]any) + if !ok { + continue + } + if propTypeKeyMap[extractBinaryID(propMap["TypePointer"])] != propertyKey { + continue + } + value, ok := propMap["Value"].(map[string]any) + if !ok { + continue + } + if img, ok := value["Image"].(string); ok { + return img + } + } + return "" +} + func extractCustomWidgetPropertyString(ctx *ExecContext, w map[string]any, propertyKey string) string { obj, ok := w["Object"].(map[string]any) if !ok { @@ -1124,6 +1157,7 @@ func extractExplicitProperties(ctx *ExecContext, w map[string]any) []rawExplicit // extractImageProperties extracts properties from a pluggable Image CustomWidget. func extractImageProperties(ctx *ExecContext, w map[string]any, widget *rawWidget) { widget.ImageType = extractCustomWidgetPropertyString(ctx, w, "datasource") + widget.ImageObject = extractCustomWidgetPropertyImage(ctx, w, "imageObject") widget.ImageUrl = extractCustomWidgetPropertyTextTemplate(ctx, w, "imageUrl") widget.AlternativeText = extractCustomWidgetPropertyTextTemplate(ctx, w, "alternativeText") widget.ImageWidth = extractCustomWidgetPropertyString(ctx, w, "width") diff --git a/mdl/executor/validate_widget_image.go b/mdl/executor/validate_widget_image.go index 6282eb120..8885af42f 100644 --- a/mdl/executor/validate_widget_image.go +++ b/mdl/executor/validate_widget_image.go @@ -12,31 +12,32 @@ import ( // MDL-WIDGET22: an IMAGE widget with nothing to show. // -// The Image widget's `datasource` property defaults to `image` — an image from -// an image collection — and MDL has no way to say WHICH image: the widget's -// `imageObject` property is of type `image`, an operation the pluggable widget -// engine does not implement. So the default spelling -// -// image i (Responsive: false) -// -// writes a model mxbuild refuses: +// The Image widget's `datasource` property selects where the image comes from, +// and it defaults to `image` — an entry from an image collection, named on the +// widget's `imageObject` property. A widget with that source and no entry is a +// model mxbuild refuses: // // [error] "No image selected." at Image 'i' // // This was invisible until the CE0463 fix landed. Every mxcli-authored Image // failed the build with CE0463 "the definition of this widget has changed" — // which fired first, and which `mx update-widgets` cleared, leaving the real -// error behind it (mxcli-formula1 FINDINGS §69/§142). It is also why a -// describe → exec copy of an Atlas layout loses its brand image. +// error behind it (mxcli-formula1 FINDINGS §69/§142). +// +// When this rule was first written MDL could not name an image collection entry +// at all, so its only advice was to switch source. `Image:` is now authorable +// (see the `image` operation in widget_engine.go), so the rule reports a +// genuinely incomplete widget rather than a missing capability — and the remedy +// it offers is the one that keeps the author's intent. // -// Reported at check time because that is where it costs a second rather than a -// build. It is an ERROR: the build does not pass, so accepting it would be -// mxcli passing a script it knows produces a broken app. +// Reported at check time because that costs a second rather than a build. It is +// an ERROR: the build does not pass, so accepting it would be mxcli waving +// through a script it knows produces a broken app. const imageSourceRule = "MDL-WIDGET22" // imageSourceNeedsImage lists the `datasource` values that require an image -// reference MDL cannot yet author. Anything else — including a value this build -// does not recognise — is left alone rather than guessed at. +// collection entry. Anything else — including a value this build does not +// recognise — is left alone rather than guessed at. var imageSourceNeedsImage = map[string]bool{ "": true, // absent: the widget's own default is "image" "image": true, @@ -52,6 +53,9 @@ func validateImageSource(w *ast.WidgetV3, locationPrefix string) []linter.Violat switch { case imageSourceNeedsImage[strings.ToLower(source)]: + if strings.TrimSpace(w.GetStringProp("Image")) != "" { + return nil // an entry is named — the widget is complete + } return []linter.Violation{{ RuleID: imageSourceRule, Severity: linter.SeverityError, @@ -61,11 +65,12 @@ func validateImageSource(w *ast.WidgetV3, locationPrefix string) []linter.Violat "build with \"No image selected.\"", locationPrefix, w.Name), Location: linter.Location{DocumentType: "page"}, - Suggestion: "MDL cannot yet point an image widget at an image collection entry " + - "(the widget's `imageObject` property is of a type the widget engine does not " + - "author). Use the URL form instead — `image " + w.Name + - " (ImageType: imageUrl, ImageUrl: 'https://…')` — or `ImageType: icon`, or set " + - "the image in Studio Pro.", + Suggestion: fmt.Sprintf( + "Name the entry: `image %s (Image: 'Module.Collection.ImageName')` — "+ + "`show image collections` lists them, and `describe image collection "+ + "Module.Collection` lists the images inside one. Or switch source: "+ + "`ImageType: imageUrl, ImageUrl: 'https://…'`, or `ImageType: icon`.", + w.Name), }} case strings.EqualFold(source, "imageUrl") && strings.TrimSpace(w.GetStringProp("ImageUrl")) == "": return []linter.Violation{{ diff --git a/mdl/executor/validate_widget_image_test.go b/mdl/executor/validate_widget_image_test.go index 66cf86f81..067444495 100644 --- a/mdl/executor/validate_widget_image_test.go +++ b/mdl/executor/validate_widget_image_test.go @@ -17,10 +17,12 @@ import ( // → [error] "No image selected." at Image 'i' // // The Image widget's `datasource` defaults to `image`, which shows an image from -// an image collection — and MDL has no way to say WHICH image (the widget's -// `imageObject` property is of type `image`, an operation the widget engine does -// not implement). So the default spelling of `image` always writes a model -// mxbuild rejects. +// an image collection, named on the widget's `imageObject` property. +// +// When this rule was written MDL could not name one at all, so the only advice +// it could give was to switch source. `Image:` is authorable now, so the rule +// reports a genuinely incomplete widget — and the case that must NOT fire is a +// widget that names its entry. // // CE0463 was masking that, which is why it went unnoticed: the definition error // fired first and `mx update-widgets` "fixed" the page by clearing it, leaving @@ -56,9 +58,13 @@ func TestImageWidget_DefaultSourceWithNoImageIsReported(t *testing.T) { if !strings.Contains(got[0].Message, "No image selected") { t.Errorf("the message should quote what mxbuild says: %s", got[0].Message) } - // It must offer the spelling that does work, not just refuse. + // The suggestion must lead with the remedy that keeps the author's intent — + // naming the entry — not only with "use a different source". + if !strings.Contains(got[0].Suggestion, "Image: 'Module.Collection.ImageName'") { + t.Errorf("the suggestion should show how to name the entry: %s", got[0].Suggestion) + } if !strings.Contains(got[0].Suggestion, "imageUrl") { - t.Errorf("the suggestion should name the working form: %s", got[0].Suggestion) + t.Errorf("the suggestion should still offer the alternative sources: %s", got[0].Suggestion) } } @@ -114,3 +120,27 @@ func TestImageWidget_EmptyUrlIsReported(t *testing.T) { t.Fatalf("got %d violations, want 1: %+v", len(got), got) } } + +// The capability this rule used to say did not exist: an image collection entry +// named on the widget. It must not be reported. +func TestImageWidget_NamedCollectionEntryIsClean(t *testing.T) { + for _, props := range []map[string]any{ + {"Image": "MyFirstModule.Images._1"}, // default source + {"ImageType": "image", "Image": "MyFirstModule.Images._1"}, // source spelled out + } { + if got := imageViolations(validateImageSource(imageWidget(props), "page X")); len(got) != 0 { + t.Errorf("a widget naming its image was reported: %+v", got) + } + } +} + +// CONTROL: an empty Image is not an image. Whitespace is not either — the value +// reaches the writer verbatim, so " " would store a reference to nothing. +func TestImageWidget_EmptyOrBlankImageIsStillReported(t *testing.T) { + for _, v := range []string{"", " "} { + got := imageViolations(validateImageSource(imageWidget(map[string]any{"Image": v}), "page X")) + if len(got) != 1 { + t.Errorf("Image=%q: got %d violations, want 1", v, len(got)) + } + } +} diff --git a/mdl/executor/widget_defs.go b/mdl/executor/widget_defs.go index a9958d24f..7f838d14b 100644 --- a/mdl/executor/widget_defs.go +++ b/mdl/executor/widget_defs.go @@ -637,6 +637,8 @@ func operationForType(t string) string { return "expression" case "action": return "action" + case "image": + return "image" case "boolean", "integer", "decimal", "string", "enumeration": return "primitive" } diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index b94f6e0a9..670348121 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -675,6 +675,11 @@ func (e *PluggableWidgetEngine) applyOperation(builder backend.WidgetObjectBuild builder.SetPrimitive(propKey, ctx.PrimitiveVal) case "selection": builder.SetSelection(propKey, ctx.PrimitiveVal) + case "image": + // An image collection entry, by its three-part qualified name. The + // value arrives through the generic source branch, so it is in + // PrimitiveVal like any other scalar — only the key it lands on differs. + builder.SetImage(propKey, ctx.PrimitiveVal) case "expression": builder.SetExpression(propKey, ctx.PrimitiveVal) case "datasource": diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index fa2880b2e..57ed8a518 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -34,6 +34,10 @@ var defaultKnownOperations = map[string]bool{ "texttemplate": true, "action": true, "attributeObjects": true, + // An image collection entry on an image-typed property (the Image widget's + // `imageObject`). Without it the property is unreachable from MDL and the + // widget's default source has nothing to show — see setImageValue. + "image": true, } // knownOperations is the active set used for validation, initialized from diff --git a/modelsdk/widgets/definitions/image.def.json b/modelsdk/widgets/definitions/image.def.json index 7aae9123c..55e12e71f 100644 --- a/modelsdk/widgets/definitions/image.def.json +++ b/modelsdk/widgets/definitions/image.def.json @@ -10,6 +10,11 @@ "default": "image", "operation": "primitive" }, + { + "propertyKey": "imageObject", + "source": "Image", + "operation": "image" + }, { "propertyKey": "imageUrl", "source": "ImageUrl", diff --git a/sdk/widgets/definitions/image.def.json b/sdk/widgets/definitions/image.def.json index 7aae9123c..55e12e71f 100644 --- a/sdk/widgets/definitions/image.def.json +++ b/sdk/widgets/definitions/image.def.json @@ -10,6 +10,11 @@ "default": "image", "operation": "primitive" }, + { + "propertyKey": "imageObject", + "source": "Image", + "operation": "image" + }, { "propertyKey": "imageUrl", "source": "ImageUrl", From 4236c669d480140860975a6fec7fc4e4a11e68bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 07:37:44 +0000 Subject: [PATCH 06/35] feat(check): resolve an image widget's collection entry reference Wiring `Image:` gave MDL a new qualified name to get wrong, and the first thing a typo did was slip past the reference check and fail the build: image imgTypo (Image: 'MyFirstModule.Images.NoSuchImage') mxcli check --references -> Check passed! mx check -> [CE1613] "The selected image 'MyFirstModule.Images.NoSuchImage' no longer exists." Every other name a widget carries - microflow, nanoflow, page, snippet, entity - is already resolved by validateWidgetReferences. This is the same check for the one reference that has THREE parts, so which half is wrong decides where the author has to look and the two mistakes get two messages: a missing collection sends them to `show image collections`, a missing image to that collection's contents, which the diagnostic lists. A name that is not three parts is reported as the shape mistake it is rather than as a missing image. A nil name set means the collections could not be read at all and nothing is reported - calling every image missing on a failed read is the wrong direction to be wrong in. An empty non-nil set is a real answer: the project has no image collections, so the reference is wrong. No same-script exemption, unlike every other branch: MDL cannot create an image collection entry, so the reference can only resolve against the project. Measured against a real 11.13 project - all three mistakes reported with the right message. Controls: TestImageRefErrors_KnownImageIsClean (an existing image, and a case difference, are not reported) and TestImageRefErrors_NoCollectionsMeansNoOpinion. --- .claude/skills/fix-issue.md | 1 + mdl/executor/helpers.go | 20 ++- mdl/executor/validate_widget_image_ref.go | 121 ++++++++++++++++++ .../validate_widget_image_ref_test.go | 94 ++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 mdl/executor/validate_widget_image_ref.go create mode 100644 mdl/executor/validate_widget_image_ref_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 1a5846af2..2dd56171b 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -761,3 +761,4 @@ extracting `OffsetExpression`/`LimitExpression`. | Every `IMAGE` widget mxcli authors on Mendix 11.13 fails `mx check` with **CE0463** "the definition of this widget has changed", including the exact `pluggablewidget` form an existing bug-test documents as fixed. `mxcli fix widgets` clears it | `mdl/executor/widget_engine.go` (`hiddenUnnamedProperties`, the mapping loop) | **Case B**, established before any hypothesis (diagnose-ce0463 Step 0): the baseline blank project ships **10 Studio Pro-authored widgets of the same widget id** and reports **0** CE0463; one mxcli-authored Image makes it 1. So the tool is the variable — and those 10 are a known-good reference in the same project, better than a template extraction. The exhaustive path diff came back with **every path present on both sides** and four differing values, two of them content; the `Type` (PropertyTypes schema) subtree and the `TypePointer`→`PropertyKey` mapping were identical. **Two hypotheses tested and falsified, which is why they are recorded**: (a) *key order* — mxcli's node is not in the reference's alphabetical order, a documented CE0463 cause, but the same page's mxcli-authored Datagrid and Badge share that order and pass; (b) *the width value* — authoring `Width: 48` explicitly passes, so 48 is not rejected. The cause is the interaction: the widget **hides `width` when `widthUnit` is "auto"**, and a hidden property must hold its DECLARED default. The engine *skipped* the mapping for a hidden property, which leaves the widget **template's captured value** — image.json holds 48 while its own `ValueType.DefaultValue` says 100. mxcli's own **MDL-WIDGET10** already said so verbatim ("a non-default value there fails the build with CE0463 (the default is \"100\")"), so `check` refused what `exec` emitted: the fix makes the writer read `widgetPropertyDefaults`, the checker's own source, so the two cannot disagree again. The engine's comment already stated the invariant correctly — "the hidden ones **at their default**, so hidden means default-valued, not absent" — and skipping only coincides with that when the template happens to be at the default. Where no default can be looked up (a datasource has none) it still skips, so #956's File Uploader pruning is unchanged. **Control**: stub the `SetPrimitive` and CE0463 returns end-to-end. Follow-on the fix *revealed* (CE0463 was masking it): the default `ImageType: image` needs an image-collection entry MDL cannot name, so the bare form builds to Mendix's own "No image selected." — now **MDL-WIDGET22** at check time, and it found the same breakage in four of the repo's own examples. Reported as mxcli-formula1 FINDINGS §69/§142 | | A `RETRIEVE … WHERE a = $Var/Attr AND b = $Var/Attr` passes `mxcli check` and the build fails **CE0161** "Error(s) in XPath constraint" — but the same statement with **literals** on both sides of the same uppercase `AND` builds fine | `mdl/visitor/xpath_operators.go` (new, `NormalizeXPathOperators`), called from `mdl/visitor/xpath_format.go` | XPath 1.0 spells `and`/`or`/`not` in **lower case only**; MDL's lexer accepts any case (`AND: A N D;`). mxcli lowercased the operator on **one of two rendering paths**: `expressionToXPath` does it while walking the parse tree, but `buildRetrieveWhereExpression` freezes the **raw source** whenever the clause contains a `/` — which every variable path has — and `expressionToXPath`'s `SourceExpr` case hands that text back verbatim. So the casing survived exactly when a path was present, which is why the reporter's literal-only reproducer built cleanly and the report looked not-reproducible. **The correlation was the whole difficulty**: a workaround found under time pressure records what you changed, not what was wrong. Fixed at `FormatXPathConstraint`, the single choke point all three constraint writers share (retrieve, page data source, entity access rule — the latter two measured as broken the same way before the fix), and **before** its width test, because the short branch returns the caller's own bytes. The replacement is token-based and literal-aware for two reasons that are each a worse bug than the one being fixed: rewriting inside `'A AND B'` silently changes which rows match, and an identifier that merely contains the letters (`Brand`, `Andrew`, `NOTES`, `Order_Andon`, `Module.Handover`) is not an operator. `div`/`mod` are deliberately excluded — nothing in MDL emits them, and a rewrite nothing needs can only be wrong. Example `mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl`. Unrelated and pre-existing, found alongside: `$currentUser/...` in a **page** data source constraint is CE0161 regardless of operator case. Reported as mxcli-formula1 FINDINGS §80 | | An `image` widget mxcli wrote shows nothing, and `mx check` fails with **"No image selected."**; and a `describe` → rename → `exec` copy of an Atlas layout (or any page with a brand image) comes out with the image gone, describe having emitted `image staticImage1 (Responsive: false)` with no reference at all | `mdl/executor/widget_engine.go` (the `image` operation), `mdl/executor/widget_defs.go` + `mdl/executor/widget_registry.go` (`operationForType`, `defaultKnownOperations`), `mdl/backend/widgetobj/builder.go` (`SetImage`, `setImageValue`), `mdl/backend/mutation.go` + `mdl/backend/mcp/widget.go`, `{modelsdk,sdk}/widgets/definitions/image.def.json` (`imageObject` → `Image`), `mdl/executor/cmd_pages_describe_pluggable.go` (`extractCustomWidgetPropertyImage`) + `cmd_pages_describe_output.go` (`describeImageWidgetProps`) | MDL had no spelling for **which** image an image widget shows, so its default source (`ImageType: image`, an image collection entry) could never be satisfied and describe had nothing to emit — mxcli-formula1 FINDINGS §142. The name is three parts like an icon reference, `Module.Collection.Image`, stored as a plain string on the `WidgetValue`'s `Image` key. Both halves are needed: with only the write, a round trip still loses it. `setImageValue` **replaces** the existing `Image` key and never adds one — adding a key the widget definition does not declare is the CE0463 shape. Controls: stub `setImageValue` → `TestSetImageValue_SetsTheQualifiedName` fails; stub the describe emission → `TestDescribeImageWidget_EmitsTheImageReference` fails | +| `image imgTypo (Image: 'MyFirstModule.Images.NoSuchImage')` passes `mxcli check --references` ("Check passed!") and the build then fails **CE1613** "The selected image '…' no longer exists." | `mdl/executor/validate_widget_image_ref.go` (new, `imageRefErrors`, `buildImageQualifiedNames`), `mdl/executor/helpers.go` (`widgetRefCollector.images`, `validateWidgetReferences`) | Wiring `Image:` created a new qualified name and nothing resolved it — every other name a widget carries (microflow, nanoflow, page, snippet, entity) already was. This one has **three** parts, so the two mistakes get two messages: a missing collection sends the reader to `show image collections`, a missing image to that collection's contents (which the diagnostic lists). A **nil** name set means the collections could not be read and nothing is reported; an empty non-nil one is a real answer. No same-script exemption — MDL cannot create an image collection entry, so the reference can only resolve against the project. Control: `TestImageRefErrors_KnownImageIsClean`, and `TestImageRefErrors_NoCollectionsMeansNoOpinion` for the failed-read direction | diff --git a/mdl/executor/helpers.go b/mdl/executor/helpers.go index 7a1c5b43f..abba155a0 100644 --- a/mdl/executor/helpers.go +++ b/mdl/executor/helpers.go @@ -263,6 +263,13 @@ func validateWidgetReferences(ctx *ExecContext, widgets []*ast.WidgetV3, sc *scr } } + if len(refs.images) > 0 { + // No same-script exemption: MDL cannot create an image collection entry, + // so an image reference can only ever resolve against the project. + known, collections := buildImageQualifiedNames(ctx) + errors = append(errors, imageRefErrors(refs.images, known, collections)...) + } + return errors } @@ -273,6 +280,7 @@ type widgetRefCollector struct { pages []string snippets []string entities []string + images []string } // dedupe collapses repeated references within each category, preserving first @@ -286,6 +294,7 @@ func (c *widgetRefCollector) dedupe() { c.pages = uniqueStrings(c.pages) c.snippets = uniqueStrings(c.snippets) c.entities = uniqueStrings(c.entities) + c.images = uniqueStrings(c.images) } // uniqueStrings returns s with duplicate values removed, preserving order. @@ -307,7 +316,8 @@ func uniqueStrings(s []string) []string { func (c *widgetRefCollector) empty() bool { return len(c.microflows) == 0 && len(c.nanoflows) == 0 && - len(c.pages) == 0 && len(c.snippets) == 0 && len(c.entities) == 0 + len(c.pages) == 0 && len(c.snippets) == 0 && len(c.entities) == 0 && + len(c.images) == 0 } func (c *widgetRefCollector) collectFromWidgets(widgets []*ast.WidgetV3) { @@ -345,6 +355,14 @@ func (c *widgetRefCollector) collectFromWidget(w *ast.WidgetV3) { c.snippets = append(c.snippets, snippet) } + // An image widget's Image collection entry. Only the image widget spells it + // this way; `Image` on any other widget type is not a collection reference. + if strings.EqualFold(w.Type, "image") { + if img := strings.TrimSpace(w.GetStringProp("Image")); img != "" { + c.images = append(c.images, img) + } + } + // Recurse into children c.collectFromWidgets(w.Children) } diff --git a/mdl/executor/validate_widget_image_ref.go b/mdl/executor/validate_widget_image_ref.go new file mode 100644 index 000000000..a2daa3f9a --- /dev/null +++ b/mdl/executor/validate_widget_image_ref.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "sort" + "strings" +) + +// Resolving the `Image:` reference an image widget now carries. +// +// Wiring the `image` operation gave MDL a new qualified name to get wrong, and +// the first thing a typo did was slip past the reference check and fail the +// build instead: +// +// image imgTypo (Image: 'MyFirstModule.Images.NoSuchImage') +// +// mxcli check --references -> Check passed! +// mx check -> [CE1613] "The selected image +// 'MyFirstModule.Images.NoSuchImage' no longer exists." +// +// Every other name a widget can carry — microflow, nanoflow, page, snippet, +// entity — is resolved by validateWidgetReferences. This is the same check for +// the one reference that has THREE parts: Module.Collection.Image. Which half is +// wrong decides where the author has to look, so the two mistakes get two +// messages. + +// imageRefErrors reports the image references in refs that do not resolve. +// +// known holds every image as a lower-cased three-part qualified name; +// collections holds every image collection as a lower-cased two-part one. A nil +// map means the project's collections could not be listed — then nothing is +// reported, because calling every image missing on a failed read is the wrong +// direction to be wrong in. An empty non-nil map is a real answer: the project +// has no image collections, so any reference to one is wrong. +func imageRefErrors(refs []string, known map[string]bool, collections map[string]bool) []string { + var errors []string + for _, ref := range refs { + ref = strings.TrimSpace(ref) + if ref == "" { + continue + } + parts := strings.Split(ref, ".") + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + errors = append(errors, fmt.Sprintf( + "invalid image reference: %s — an image is named "+ + "Module.Collection.ImageName (three parts); `show image collections` "+ + "lists the collections", + ref)) + continue + } + if collections == nil || known == nil { + continue // nothing to resolve against + } + + collection := parts[0] + "." + parts[1] + if !collections[strings.ToLower(collection)] { + errors = append(errors, fmt.Sprintf( + "image collection not found: %s (referenced as %s) — "+ + "`show image collections` lists them", + collection, ref)) + continue + } + if !known[strings.ToLower(ref)] { + msg := fmt.Sprintf("image not found: %s in image collection %s", parts[2], collection) + if inCollection := imagesInCollection(known, collection); len(inCollection) > 0 { + msg += fmt.Sprintf(" — it holds: %s", strings.Join(inCollection, ", ")) + } else { + msg += fmt.Sprintf(" — `describe image collection %s` lists its images", collection) + } + errors = append(errors, msg) + } + } + return errors +} + +// imagesInCollection returns the image names known for one collection, so the +// diagnostic can show what is actually there instead of only what is not. The +// names are lower-cased (that is how the set is keyed) and capped, since a +// collection can hold hundreds. +func imagesInCollection(known map[string]bool, collection string) []string { + prefix := strings.ToLower(collection) + "." + var names []string + for qn := range known { + if strings.HasPrefix(qn, prefix) { + names = append(names, strings.TrimPrefix(qn, prefix)) + } + } + sort.Strings(names) + const max = 10 + if len(names) > max { + names = append(names[:max:max], "…") + } + return names +} + +// buildImageQualifiedNames returns the project's images as lower-cased +// three-part qualified names, and its image collections as lower-cased two-part +// ones. Both are nil when the collections could not be read at all — the caller +// distinguishes that from "there are none". +func buildImageQualifiedNames(ctx *ExecContext) (images map[string]bool, collections map[string]bool) { + h, err := getHierarchy(ctx) + if err != nil { + return nil, nil + } + ics, err := ctx.Backend.ListImageCollections() + if err != nil { + return nil, nil + } + images = make(map[string]bool) + collections = make(map[string]bool) + for _, ic := range ics { + qn := strings.ToLower(h.GetQualifiedName(ic.ContainerID, ic.Name)) + collections[qn] = true + for _, img := range ic.Images { + images[qn+"."+strings.ToLower(img.Name)] = true + } + } + return images, collections +} diff --git a/mdl/executor/validate_widget_image_ref_test.go b/mdl/executor/validate_widget_image_ref_test.go new file mode 100644 index 000000000..8c8d60e1d --- /dev/null +++ b/mdl/executor/validate_widget_image_ref_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// Wiring `Image:` gave MDL a new reference to get wrong, and the first thing it +// did was reproduce this session's recurring shape: a typo passed +// `mxcli check --references` and failed the build. +// +// image imgTypo (Image: 'MyFirstModule.Images.NoSuchImage') +// +// mxcli check --references -> Check passed! +// mx check -> [CE1613] "The selected image +// 'MyFirstModule.Images.NoSuchImage' no longer exists." +// +// Every other qualified name a widget can carry — microflow, nanoflow, page, +// snippet, entity — is resolved by validateWidgetReferences. An image is now +// one of them. +// +// The name has THREE parts, unlike the rest: Module.Collection.Image. So both +// halves have to be checked, and the message has to say which one is wrong — +// "collection not found" and "that collection has no such image" send the +// reader to different places. + +func TestImageRefErrors_UnknownImageIsReported(t *testing.T) { + known := map[string]bool{"myfirstmodule.images._1": true} + collections := map[string]bool{"myfirstmodule.images": true} + + errs := imageRefErrors([]string{"MyFirstModule.Images.NoSuchImage"}, known, collections) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + if !strings.Contains(errs[0], "NoSuchImage") { + t.Errorf("the message should name the image: %s", errs[0]) + } + // The collection exists, so the reader needs to know it is the image that is + // wrong — and what the collection does hold. + if !strings.Contains(errs[0], "MyFirstModule.Images") { + t.Errorf("the message should name the collection: %s", errs[0]) + } +} + +// A missing COLLECTION is a different mistake and gets a different message. +func TestImageRefErrors_UnknownCollectionSaysSo(t *testing.T) { + errs := imageRefErrors([]string{"Nope.Missing.Img"}, map[string]bool{}, map[string]bool{}) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + if !strings.Contains(errs[0], "image collection") || !strings.Contains(errs[0], "Nope.Missing") { + t.Errorf("the message should name the missing collection: %s", errs[0]) + } +} + +// CONTROL: an image that exists is not reported. Without this the check just +// forbids the feature. +func TestImageRefErrors_KnownImageIsClean(t *testing.T) { + known := map[string]bool{"myfirstmodule.images._1": true} + collections := map[string]bool{"myfirstmodule.images": true} + + if errs := imageRefErrors([]string{"MyFirstModule.Images._1"}, known, collections); len(errs) != 0 { + t.Errorf("an existing image was reported: %v", errs) + } + // Mendix resolves names case-insensitively, and so must this. + if errs := imageRefErrors([]string{"myfirstmodule.IMAGES._1"}, known, collections); len(errs) != 0 { + t.Errorf("a case difference was reported: %v", errs) + } +} + +// A name that is not three parts cannot be resolved, and is reported as the +// shape mistake it is rather than as a missing image. +func TestImageRefErrors_MalformedNameIsReported(t *testing.T) { + for _, ref := range []string{"JustOne", "Module.Collection"} { + errs := imageRefErrors([]string{ref}, map[string]bool{}, map[string]bool{}) + if len(errs) != 1 { + t.Fatalf("%q: got %d errors, want 1: %v", ref, len(errs), errs) + } + if !strings.Contains(errs[0], "Module.Collection.ImageName") { + t.Errorf("%q: the message should show the expected shape: %s", ref, errs[0]) + } + } +} + +// CONTROL: with no image collections readable at all, nothing is reported. +// A project whose collections could not be listed must not have every image +// called missing — that is the "guessing wrong rejects working input" direction. +func TestImageRefErrors_NoCollectionsMeansNoOpinion(t *testing.T) { + if errs := imageRefErrors([]string{"Mod.Coll.Img"}, nil, nil); len(errs) != 0 { + t.Errorf("reported an image with nothing to resolve against: %v", errs) + } +} From 5b200d3259a6a164ac50164606dd9d6f10c9fa7b Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 31 Aug 2026 18:49:10 +0000 Subject: [PATCH 07/35] fix(describe): quote and escape MDL strings in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESCRIBE WORKFLOW emitted single-quoted payloads without doubling the quotes inside them, so its own output was a syntax error: targeting users xpath '[System.UserRoles = '[%UserRole_Banker%]']' -> line 6:48 mismatched input '[%UserRole_Banker%]' expecting ';' The string terminates at `= '` and every later statement cascades, so DESCRIBE WORKFLOW output could not be fed back to check or exec for any workflow with an XPath-targeted user task. The reported case is one of six. The emitters wrote fmt.Sprintf("... '%s'", v) with the quotes in the format string and the escaping, when present, as a separate statement at the call site — one thing to remember per site, and 6 of 23 sites did not do it: both `targeting … xpath` variants, the user task caption, the workflow-level due date (the task-level one two functions away does escape), and both outcome values. The caption needs no XPath to reach: `Manager's review`. mdlQuoted(s) now returns the complete literal, quotes included, so an unescaped emit cannot be written by omission, and all 23 sites go through it. Tests assert the emitted MDL PARSES rather than that it contains a particular escape — a substring assertion would encode the escape under test and pass for the wrong reason. A source scan guards the class, because emit tests only cover the positions they construct and the real failure mode is a new site added later. Reported as mendixlabs/mxcli#1006 (the XPath variant only). Co-Authored-By: Claude Opus 5 --- .claude/skills/fix-issue.md | 1 + mdl/executor/cmd_workflows.go | 55 +++----- mdl/executor/identifier_quoting.go | 22 +++ .../issue1006_emitter_quoting_test.go | 132 ++++++++++++++++++ 4 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 mdl/executor/issue1006_emitter_quoting_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 021278b06..955b0735c 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -758,3 +758,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `mxcli run --local`/`--hub` sits at several hundred percent CPU for hours while the app is gone; `ps -o stat` shows the runtime JVM as `Z` (zombie) under it, `curl localhost:` returns `000`, and the hub preview URL still answers | `cmd/mxcli/docker/localboot.go` (`watchExit`, `alive`, `stopProcess`), `cmd/mxcli/docker/runlocal.go` (`waitForInterruptOrExit`, `runtimeStoppedError`) | Two mechanisms. (1) **Nothing ever called `Wait()`** on the runtime process, so an exited JVM stayed an unreaped zombie — and `alive()` asked `Signal(0)`, which **succeeds on a zombie**. Measured (Linux 6.18, go1.26): proc state `Z`, `Signal(0)` → nil; after `Wait()` → "process already finished". So the liveness check reported a runtime that had terminated itself hours earlier as alive. `Signal(0)` is only a correct liveness test *because* something reaps — removing `watchExit` silently breaks that line, which is why the control is on the reaper, not on `alive()`. (2) **After boot, `run` waited on a signal and nothing else**, so a correct answer had no one asking; it now waits on the signal OR `rt.Exited()` and returns a **non-zero** error, because returning 0 after the app has gone is what let a supervisor conclude all was well. **Why it happens at all**: the local standalone runtime uses a development licence with a maximum run time and terminates *itself* (measured lifetimes 3h52m and 5h07m — not a fixed number, and shorter than a working session); `runtimeExitReason` lifts that from the runtime's own log, and reports nothing rather than guessing when it cannot tell. **Two waiters on one process deadlock**, so `stopProcess` consults the reaper's channel instead of taking its own `Wait`. The CPU spin itself was NOT reproduced and is not claimed fixed by name — it lived in the tunnel client, under a supervisor blind to its dead child; what is fixed is the state it occurred in, since mxcli now exits and takes the tunnel with it. **Generalisable**: a 200 from a tunnelled URL is not evidence the app is alive — the tunnel outlives the runtime. Reported as mxcli-formula1 FINDINGS §60 | | After `mxcli test … --local`, an app another `mxcli run --local` is serving goes blank while still answering HTTP 200 (~1.7 KB, the Mendix SPA shell); the runtime log shows `Connector: 404 - file not found for file: dist%2Findex.js` and `deployment/web/dist` is gone | `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go` (`localTestDeployDir`), `cmd/mxcli/testrunner/runner.go` (`checkScratchDeploymentExists`) | A local test run already used its own ports and its own `_test` database — the code comment says why, verbatim — but shared the **deployment directory**, which is the one the *browser* reads. A headless test boot does not bundle the web client, so its build left the running app serving the shell over a 404: tests pass, run keeps running, app is blank, nothing reported at either end. **Detection was not the fix**: the two processes use different ports by design, so no port check can see it, and a lock file would only turn a silent blanking into a refusal. The test boot now builds into `/.mxcli/deployment-test/` — gitignored, already where the test runtime log lives — which makes the collision impossible. Note booting a runtime against the shared directory damages it even **without** a rebuild (the packaging step removes the bundle — FINDINGS §35, `ReportLostWebClientBundle`), so "reuse the dev loop's tree read-only" is not an alternative. Consequence to wire: `--skip-build` used to mean "reuse deployment/" and now has nothing until tests have run once, so it is refused with the reason rather than failing inside the runtime boot against a path the user never chose. Reported as mxcli-formula1 FINDINGS §62 | | A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — "absence from the documentation is not absence from the grammar" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → "widget *pages.X not yet supported by the modelsdk engine"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 | +| `DESCRIBE WORKFLOW` output fails `mxcli check` — `mismatched input '[%UserRole_Banker%]' expecting ';'` on a `targeting users xpath` line, and every later statement cascades | The emitter wrote `fmt.Sprintf("… '%s'", v)` — quotes in the format string, escaping (if any) at the call site. **6 of 23 emit sites did not escape**: both xpath variants, the user-task caption, the workflow-level due date, and both outcome values | `mdl/executor/cmd_workflows.go` (all 23 sites), `mdl/executor/identifier_quoting.go` (`mdlQuoted`) | **Return the quotes WITH the escaping** — `mdlQuoted(s)` yields `'…''…'` complete, so an unescaped emit cannot be written by omission. Escaping at the call site is one thing to remember per site, and the count of sites only grows. Assert the emitted MDL **parses** (wrap the fragment in a minimal `create workflow … end workflow;` and run `visitor.Build`), never that it contains a particular escape — a substring assertion encodes the very escape under test, so it passes for the wrong reason. Route the harness through `formatWorkflowActivities`, not the per-activity formatter: the statement terminator is appended by the caller, so calling the formatter directly produces unparseable output for reasons unrelated to the bug. Emit tests only cover positions the test constructs, so add a **source scan** for a literal `'%s'` in the describers — the real failure mode is a *new* site added later, and `mdlQuoted` carrying its own quotes is what makes that scan sound. The reported symptom was the XPath (its payload is full of quoted constraints, so *every* XPath-targeted user task hits it); the caption one needs only an apostrophe — `Manager's review`. mendixlabs/mxcli#1006 | diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index d9b1083f8..e9d3b1da1 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -197,14 +197,12 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, // Display name if targetWf.WorkflowName != "" { - escaped := strings.ReplaceAll(targetWf.WorkflowName, "'", "''") - lines = append(lines, fmt.Sprintf(" display '%s'", escaped)) + lines = append(lines, fmt.Sprintf(" display %s", mdlQuoted(targetWf.WorkflowName))) } // Description if targetWf.WorkflowDescription != "" { - escaped := strings.ReplaceAll(targetWf.WorkflowDescription, "'", "''") - lines = append(lines, fmt.Sprintf(" description '%s'", escaped)) + lines = append(lines, fmt.Sprintf(" description %s", mdlQuoted(targetWf.WorkflowDescription))) } // Export level (only emit when non-empty) @@ -219,7 +217,7 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, // Due date if targetWf.DueDate != "" { - lines = append(lines, fmt.Sprintf(" due date '%s'", targetWf.DueDate)) + lines = append(lines, fmt.Sprintf(" due date %s", mdlQuoted(targetWf.DueDate))) } lines = append(lines, "") @@ -243,8 +241,7 @@ func formatAnnotation(annotation string, indent string) string { if annotation == "" { return "" } - escaped := strings.ReplaceAll(annotation, "'", "''") - return fmt.Sprintf("%sannotation '%s';", indent, escaped) + return fmt.Sprintf("%sannotation %s;", indent, mdlQuoted(annotation)) } // boundaryEventKeyword maps an EventType string to the MDL BOUNDARY EVENT keyword sequence. @@ -269,8 +266,7 @@ func formatBoundaryEvents(events []*workflows.BoundaryEvent, indent string) []st for _, event := range events { keyword := boundaryEventKeyword(event.EventType) if event.TimerDelay != "" { - escapedDelay := strings.ReplaceAll(event.TimerDelay, "'", "''") - lines = append(lines, fmt.Sprintf("%s%s '%s'", indent, keyword, escapedDelay)) + lines = append(lines, fmt.Sprintf("%s%s %s", indent, keyword, mdlQuoted(event.TimerDelay))) } else { lines = append(lines, fmt.Sprintf("%s%s", indent, keyword)) } @@ -323,8 +319,7 @@ func formatWorkflowActivities(flow *workflows.Flow, indent string) []string { // (issuetracker #16). Re-applying the shorter form rebuilds the same // Caption, so dropping it is lossless. if caption := a.Caption; caption != "" && caption != target && caption != a.Name { - escapedCaption := strings.ReplaceAll(caption, "'", "''") - actLines = append(actLines, fmt.Sprintf("%sjump to %s comment '%s'", indent, mdlIdent(target), escapedCaption)) + actLines = append(actLines, fmt.Sprintf("%sjump to %s comment %s", indent, mdlIdent(target), mdlQuoted(caption))) } else { actLines = append(actLines, fmt.Sprintf("%sjump to %s", indent, mdlIdent(target))) } @@ -337,12 +332,9 @@ func formatWorkflowActivities(flow *workflows.Flow, indent string) []string { actLines = append(actLines, formatAnnotation(a.Annotation, indent)) } if a.DelayExpression != "" { - escapedDelay := strings.ReplaceAll(a.DelayExpression, "'", "''") - escapedCaption := strings.ReplaceAll(caption, "'", "''") - actLines = append(actLines, fmt.Sprintf("%swait for timer '%s' comment '%s'", indent, escapedDelay, escapedCaption)) + actLines = append(actLines, fmt.Sprintf("%swait for timer %s comment %s", indent, mdlQuoted(a.DelayExpression), mdlQuoted(caption))) } else { - escapedCaption := strings.ReplaceAll(caption, "'", "''") - actLines = append(actLines, fmt.Sprintf("%swait for timer comment '%s'", indent, escapedCaption)) + actLines = append(actLines, fmt.Sprintf("%swait for timer comment %s", indent, mdlQuoted(caption))) } case *workflows.WaitForNotificationActivity: caption := a.Caption @@ -370,8 +362,7 @@ func formatWorkflowActivities(flow *workflows.Flow, indent string) []string { case *workflows.WorkflowAnnotationActivity: // Standalone annotation (sticky note) - emit as ANNOTATION statement if a.Description != "" { - escapedDesc := strings.ReplaceAll(a.Description, "'", "''") - actLines = []string{fmt.Sprintf("%sannotation '%s'", indent, escapedDesc)} + actLines = []string{fmt.Sprintf("%sannotation %s", indent, mdlQuoted(a.Description))} } else { continue } @@ -424,7 +415,7 @@ func formatUserTask(a *workflows.UserTask, indent string) []string { if a.IsMulti { taskKeyword = "multi user task" } - lines = append(lines, fmt.Sprintf("%s%s %s '%s'", indent, taskKeyword, mdlIdent(nameStr), caption)) + lines = append(lines, fmt.Sprintf("%s%s %s %s", indent, taskKeyword, mdlIdent(nameStr), mdlQuoted(caption))) if a.Page != "" { lines = append(lines, fmt.Sprintf("%s page %s", indent, a.Page)) @@ -439,7 +430,7 @@ func formatUserTask(a *workflows.UserTask, indent string) []string { } case *workflows.XPathBasedUserSource: if us.XPath != "" { - lines = append(lines, fmt.Sprintf("%s targeting users xpath '%s'", indent, us.XPath)) + lines = append(lines, fmt.Sprintf("%s targeting users xpath %s", indent, mdlQuoted(us.XPath))) } case *workflows.MicroflowGroupSource: if us.Microflow != "" { @@ -447,7 +438,7 @@ func formatUserTask(a *workflows.UserTask, indent string) []string { } case *workflows.XPathGroupSource: if us.XPath != "" { - lines = append(lines, fmt.Sprintf("%s targeting groups xpath '%s'", indent, us.XPath)) + lines = append(lines, fmt.Sprintf("%s targeting groups xpath %s", indent, mdlQuoted(us.XPath))) } } } @@ -458,14 +449,12 @@ func formatUserTask(a *workflows.UserTask, indent string) []string { // Due date (task-level) if a.DueDate != "" { - escapedDueDate := strings.ReplaceAll(a.DueDate, "'", "''") - lines = append(lines, fmt.Sprintf("%s due date '%s'", indent, escapedDueDate)) + lines = append(lines, fmt.Sprintf("%s due date %s", indent, mdlQuoted(a.DueDate))) } // Task description if a.TaskDescription != "" { - escaped := strings.ReplaceAll(a.TaskDescription, "'", "''") - lines = append(lines, fmt.Sprintf("%s description '%s'", indent, escaped)) + lines = append(lines, fmt.Sprintf("%s description %s", indent, mdlQuoted(a.TaskDescription))) } // Outcomes @@ -480,12 +469,12 @@ func formatUserTask(a *workflows.UserTask, indent string) []string { outValue = outcome.Name } if outcome.Flow != nil && len(outcome.Flow.Activities) > 0 { - lines = append(lines, fmt.Sprintf("%s '%s' {", indent, outValue)) + lines = append(lines, fmt.Sprintf("%s %s {", indent, mdlQuoted(outValue))) subLines := formatWorkflowActivities(outcome.Flow, indent+" ") lines = append(lines, subLines...) lines = append(lines, fmt.Sprintf("%s }", indent)) } else { - lines = append(lines, fmt.Sprintf("%s '%s' { }", indent, outValue)) + lines = append(lines, fmt.Sprintf("%s %s { }", indent, mdlQuoted(outValue))) } } } @@ -521,7 +510,7 @@ func formatCallMicroflowTask(a *workflows.CallMicroflowTask, indent string) []st if idx := strings.LastIndex(paramName, "."); idx >= 0 { paramName = paramName[idx+1:] } - params = append(params, fmt.Sprintf("%s = '%s'", paramName, strings.ReplaceAll(pm.Expression, "'", "''"))) + params = append(params, fmt.Sprintf("%s = %s", paramName, mdlQuoted(pm.Expression))) } lines = append(lines, fmt.Sprintf("%scall microflow %s with (%s) -- %s", indent, mf, strings.Join(params, ", "), caption)) } else { @@ -583,7 +572,6 @@ func formatCallWorkflowActivity(a *workflows.CallWorkflowActivity, indent string wf = "?" } - escapedCaption := strings.ReplaceAll(caption, "'", "''") if len(a.ParameterMappings) > 0 { var params []string for _, pm := range a.ParameterMappings { @@ -591,11 +579,11 @@ func formatCallWorkflowActivity(a *workflows.CallWorkflowActivity, indent string if idx := strings.LastIndex(paramName, "."); idx >= 0 { paramName = paramName[idx+1:] } - params = append(params, fmt.Sprintf("%s = '%s'", paramName, strings.ReplaceAll(pm.Expression, "'", "''"))) + params = append(params, fmt.Sprintf("%s = %s", paramName, mdlQuoted(pm.Expression))) } - lines = append(lines, fmt.Sprintf("%scall workflow %s comment '%s' with (%s)", indent, wf, escapedCaption, strings.Join(params, ", "))) + lines = append(lines, fmt.Sprintf("%scall workflow %s comment %s with (%s)", indent, wf, mdlQuoted(caption), strings.Join(params, ", "))) } else { - lines = append(lines, fmt.Sprintf("%scall workflow %s comment '%s'", indent, wf, escapedCaption)) + lines = append(lines, fmt.Sprintf("%scall workflow %s comment %s", indent, wf, mdlQuoted(caption))) } // BoundaryEvents @@ -618,8 +606,7 @@ func formatExclusiveSplit(a *workflows.ExclusiveSplitActivity, indent string) [] } if a.Expression != "" { - escapedExpr := strings.ReplaceAll(a.Expression, "'", "''") - lines = append(lines, fmt.Sprintf("%sdecision '%s' -- %s", indent, escapedExpr, caption)) + lines = append(lines, fmt.Sprintf("%sdecision %s -- %s", indent, mdlQuoted(a.Expression), caption)) } else { lines = append(lines, fmt.Sprintf("%sdecision -- %s", indent, caption)) } diff --git a/mdl/executor/identifier_quoting.go b/mdl/executor/identifier_quoting.go index 4adc6cffc..ed069a3c1 100644 --- a/mdl/executor/identifier_quoting.go +++ b/mdl/executor/identifier_quoting.go @@ -3,10 +3,32 @@ package executor import ( + "strings" + antlr "github.com/antlr4-go/antlr/v4" "github.com/mendixlabs/mxcli/mdl/grammar/parser" ) +// mdlQuoted renders s as a complete MDL single-quoted string literal, doubling +// every embedded quote — the escape the MDL lexer requires (`'it”s'`), and the +// one Mendix Studio Pro's own expression syntax uses. Backslashes are NOT an +// escape here. +// +// It returns the quotes as well as the escaped body, deliberately. The +// describers used to write `fmt.Sprintf("... '%s'", escapeMe)` and escape the +// argument separately at each site, which is one thing to remember per emit — +// and six of twenty-three sites did not. The worst was a user task's +// `targeting users xpath`, whose payload is an XPath containing quoted +// constraints (`[System.UserRoles = '[%UserRole_Banker%]']`), so DESCRIBE +// WORKFLOW output would not re-parse for any workflow using one +// (mendixlabs/mxcli#1006). Keeping the quotes and the escaping in one function +// makes an unescaped emit impossible to write by omission. +// +// A companion test asserts no `'%s'` remains in the describers. +func mdlQuoted(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + // mdlIdent renders name as an MDL identifier suitable for DESCRIBE output, // double-quoting it when it would not lex as a bare IDENTIFIER — e.g. when it // collides with a reserved keyword ("List", "Column", "Template", …). This keeps diff --git a/mdl/executor/issue1006_emitter_quoting_test.go b/mdl/executor/issue1006_emitter_quoting_test.go new file mode 100644 index 000000000..2ae01edd2 --- /dev/null +++ b/mdl/executor/issue1006_emitter_quoting_test.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// mendixlabs/mxcli#1006 — DESCRIBE WORKFLOW emitted single-quoted payloads +// without doubling the quotes inside them, so its own output was a syntax error. +// +// The assertion is that the emitted MDL PARSES, not that it contains a +// particular substring. A substring test would have to encode the escape it is +// checking for, which is the thing under test; parsing is the property that +// actually matters and it cannot be satisfied by the wrong escape. + +// parsesAsWorkflowBody emits the activities through the real describe path +// (formatWorkflowActivities, which is what appends the statement terminator), +// wraps them in the smallest workflow that can contain them, and reports the +// parse errors if any. +func parsesAsWorkflowBody(t *testing.T, acts ...workflows.WorkflowActivity) []string { + t.Helper() + lines := formatWorkflowActivities(&workflows.Flow{Activities: acts}, " ") + src := "create workflow M.WF\n parameter $WorkflowContext: M.E\nbegin\n" + + strings.Join(lines, "\n") + "\nend workflow;" + _, errs := visitor.Build(src) + if len(errs) == 0 { + return nil + } + out := make([]string, len(errs)) + for i, e := range errs { + out[i] = e.Error() + } + t.Logf("emitted MDL that failed to parse:\n%s", src) + return out +} + +// The reported case: an XPath payload is itself full of single-quoted +// constraints, so this one is not an edge case — every XPath-targeted user task +// hits it. +func TestDescribeWorkflow_XPathUserSourceReparses(t *testing.T) { + task := &workflows.UserTask{} + task.Name = "Review" + task.Caption = "Review" + task.Page = "M.ReviewPage" + task.UserSource = &workflows.XPathBasedUserSource{ + XPath: `[System.UserRoles = '[%UserRole_Banker%]']`, + } + if errs := parsesAsWorkflowBody(t, task); errs != nil { + t.Errorf("targeting users xpath does not re-parse: %v", errs) + } +} + +// The same emitter, the group variant — unreported, and broken identically. +func TestDescribeWorkflow_XPathGroupSourceReparses(t *testing.T) { + task := &workflows.UserTask{} + task.Name = "Review" + task.Caption = "Review" + task.Page = "M.ReviewPage" + task.UserSource = &workflows.XPathGroupSource{ + XPath: `[System.UserRoles = '[%UserRole_Banker%]']`, + } + if errs := parsesAsWorkflowBody(t, task); errs != nil { + t.Errorf("targeting groups xpath does not re-parse: %v", errs) + } +} + +// A caption or an outcome value carrying an ordinary apostrophe. Neither needs +// an XPath to reach — "Manager's review" is a caption a person would type. +func TestDescribeWorkflow_ApostropheInCaptionAndOutcomeReparses(t *testing.T) { + task := &workflows.UserTask{} + task.Name = "Approve" + task.Caption = "Manager's review" + task.Page = "M.ReviewPage" + task.TaskDescription = "Check the customer's limit" + task.DueDate = "[%CurrentDateTime%]" + task.Outcomes = []*workflows.UserTaskOutcome{ + {Value: "Won't fix"}, + {Value: "Approved"}, + } + if errs := parsesAsWorkflowBody(t, task); errs != nil { + t.Errorf("apostrophes in caption/description/outcome do not re-parse: %v", errs) + } +} + +// mdlQuoted is the whole fix; assert the escape directly so a failure upstream +// is distinguishable from a failure in a caller. +func TestMDLQuoted(t *testing.T) { + for in, want := range map[string]string{ + "plain": "'plain'", + "it's": "'it''s'", + `[a = '[%X%]']`: `'[a = ''[%X%]'']'`, + "": "''", + `already ''doubled`: `'already ''''doubled'`, + `back\slash`: `'back\slash'`, // backslash is not an escape in MDL + } { + if got := mdlQuoted(in); got != want { + t.Errorf("mdlQuoted(%q) = %q, want %q", in, got, want) + } + } +} + +// The guard against the next omission. Six of twenty-three emit sites in the +// describers escaped nothing, and each was individually plausible — the pattern +// `fmt.Sprintf("... '%s'", v)` puts the quotes and the escaping in different +// places, so forgetting one is invisible at the call site. +// +// A source scan rather than more emit tests: a test can only cover the emit +// positions it happens to construct, and the failure mode here is a NEW site +// added later. `mdlQuoted` carries its own quotes, so a literal `'%s'` in these +// files is by construction a site that is not using it. +func TestDescribers_HaveNoHandRolledStringLiterals(t *testing.T) { + pattern := regexp.MustCompile(`'%s'`) + for _, f := range []string{"cmd_workflows.go"} { + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(src), "\n") { + if pattern.MatchString(line) { + t.Errorf("%s:%d emits a hand-rolled MDL string literal — use mdlQuoted:\n\t%s", + f, i+1, strings.TrimSpace(line)) + } + } + } +} From d41832bdaafc2fadbad3134eca8af412f270ec31 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 31 Aug 2026 18:53:12 +0000 Subject: [PATCH 08/35] fix(describe): emit workflow annotations as comments, not statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESCRIBE WORKFLOW emitted `annotation '';` — the exact construct MDL-WF04 exists to refuse, and that execCreateWorkflow refuses again. So the describer produced output mxcli's own checker rejects: 13 MDL-WF04 errors from one unmodified describe of a 23-activity workflow. Two parts of the tree disagreed, each stating its position in a comment. The emitter: "emitted as a parseable MDL statement so it survives round-trips." The validator: it "produces a model Mendix cannot load (the annotation is placed in the activity flow, which accepts only flow elements)". The emitter's comment was the stale one. The reporter read these as canvas annotations. They are not: formatAnnotation is called at 10 sites, always with an activity's ATTACHED Annotation, and the describer converted attached to standalone — which is the refused form. That matters for the fix, because MDLWorkflow.g4 has only the standalone workflowAnnotationStmt: no MDL input can express an attached annotation, even though the write path stores one. Commenting it out therefore loses nothing that was reachable. Both emit paths become comments — the attached one and the standalone WorkflowAnnotationActivity read back from a model. The standalone branch also has to mark itself a comment, or the terminator logic appends `;`. The microflow domain already has an attached form (`@annotation 'text'`) that round-trips properly. Giving workflow activities the same prefix would preserve the annotation instead of commenting it out; that is a grammar change and deliberately not bundled here. Tests assert the emitted MDL parses, passes ValidateWorkflow, AND still contains the text — the last one because dropping the annotation entirely would satisfy the first two. Reported as mendixlabs/mxcli#1007. Co-Authored-By: Claude Opus 5 --- .claude/skills/fix-issue.md | 1 + mdl/executor/cmd_workflows.go | 54 ++++++- .../issue1007_annotation_emit_test.go | 140 ++++++++++++++++++ 3 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 mdl/executor/issue1007_annotation_emit_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 955b0735c..2c8b025ac 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -759,3 +759,4 @@ extracting `OffsetExpression`/`LimitExpression`. | After `mxcli test … --local`, an app another `mxcli run --local` is serving goes blank while still answering HTTP 200 (~1.7 KB, the Mendix SPA shell); the runtime log shows `Connector: 404 - file not found for file: dist%2Findex.js` and `deployment/web/dist` is gone | `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go` (`localTestDeployDir`), `cmd/mxcli/testrunner/runner.go` (`checkScratchDeploymentExists`) | A local test run already used its own ports and its own `_test` database — the code comment says why, verbatim — but shared the **deployment directory**, which is the one the *browser* reads. A headless test boot does not bundle the web client, so its build left the running app serving the shell over a 404: tests pass, run keeps running, app is blank, nothing reported at either end. **Detection was not the fix**: the two processes use different ports by design, so no port check can see it, and a lock file would only turn a silent blanking into a refusal. The test boot now builds into `/.mxcli/deployment-test/` — gitignored, already where the test runtime log lives — which makes the collision impossible. Note booting a runtime against the shared directory damages it even **without** a rebuild (the packaging step removes the bundle — FINDINGS §35, `ReportLostWebClientBundle`), so "reuse the dev loop's tree read-only" is not an alternative. Consequence to wire: `--skip-build` used to mean "reuse deployment/" and now has nothing until tests have run once, so it is refused with the reason rather than failing inside the runtime boot against a path the user never chose. Reported as mxcli-formula1 FINDINGS §62 | | A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — "absence from the documentation is not absence from the grammar" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → "widget *pages.X not yet supported by the modelsdk engine"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 | | `DESCRIBE WORKFLOW` output fails `mxcli check` — `mismatched input '[%UserRole_Banker%]' expecting ';'` on a `targeting users xpath` line, and every later statement cascades | The emitter wrote `fmt.Sprintf("… '%s'", v)` — quotes in the format string, escaping (if any) at the call site. **6 of 23 emit sites did not escape**: both xpath variants, the user-task caption, the workflow-level due date, and both outcome values | `mdl/executor/cmd_workflows.go` (all 23 sites), `mdl/executor/identifier_quoting.go` (`mdlQuoted`) | **Return the quotes WITH the escaping** — `mdlQuoted(s)` yields `'…''…'` complete, so an unescaped emit cannot be written by omission. Escaping at the call site is one thing to remember per site, and the count of sites only grows. Assert the emitted MDL **parses** (wrap the fragment in a minimal `create workflow … end workflow;` and run `visitor.Build`), never that it contains a particular escape — a substring assertion encodes the very escape under test, so it passes for the wrong reason. Route the harness through `formatWorkflowActivities`, not the per-activity formatter: the statement terminator is appended by the caller, so calling the formatter directly produces unparseable output for reasons unrelated to the bug. Emit tests only cover positions the test constructs, so add a **source scan** for a literal `'%s'` in the describers — the real failure mode is a *new* site added later, and `mdlQuoted` carrying its own quotes is what makes that scan sound. The reported symptom was the XPath (its payload is full of quoted constraints, so *every* XPath-targeted user task hits it); the caption one needs only an apostrophe — `Manager's review`. mendixlabs/mxcli#1006 | +| `DESCRIBE WORKFLOW` output is refused by `mxcli check` with **MDL-WF04** — "a standalone `annotation` … produces a model Mendix cannot load"; 13 errors from one unmodified describe of a 23-activity workflow | The describer emitted `annotation '';` — the exact construct MDL-WF04 exists to refuse, and that `execCreateWorkflow` refuses again. Two parts of the tree disagreed **in comments**: the emitter said "emitted as a parseable MDL statement so it survives round-trips", the validator said it cannot be loaded. The emitter's comment was the stale one | `mdl/executor/cmd_workflows.go` (`formatAnnotation` + the `WorkflowAnnotationActivity` branch), refusals in `mdl/executor/validate_workflow.go` and `cmd_workflows_write.go` | **When a describer and a validator disagree, one of them has a stale comment — read both before choosing a side.** The reporter framed these as canvas annotations; they are not. `formatAnnotation` is called at 10 sites, always with an activity's **attached** `Annotation`, and describe converted attached → standalone, which is the refused form. That distinction changes the fix: the issue's "support writing annotations" needs a grammar change, because `MDLWorkflow.g4` has only the standalone `workflowAnnotationStmt` — **no MDL input can express an attached annotation today**, even though the write path stores one. So a comment loses nothing that was reachable. Note the asymmetry that caused this: the **microflow** domain has `@annotation 'text'` as an activity prefix and round-trips it properly; giving workflow activities the same prefix is the non-lossy fix, and is a feature, not this bug. Two traps: a `--` comment runs to end of line, so a multi-line annotation must be prefixed **per line** or the tail becomes stray tokens (the same failure the statement form had), and the standalone branch must set `isComment` or the terminator logic appends `;` to a comment. Assert the emitted MDL **parses AND passes ValidateWorkflow AND still contains the text** — checking only that the keyword is gone also passes for an emit that dropped the annotation entirely. mendixlabs/mxcli#1007 | diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index e9d3b1da1..2b48da69e 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -235,13 +235,50 @@ func describeWorkflowToString(ctx *ExecContext, name ast.QualifiedName) (string, return strings.Join(lines, "\n"), nil, nil } -// formatAnnotation returns an ANNOTATION statement for a workflow activity annotation. -// The annotation is emitted as a parseable MDL statement so it survives round-trips. +// formatAnnotation renders an activity's annotation as MDL comment lines. +// +// It used to emit `annotation '';`, and its own doc comment claimed that +// statement "survives round-trips". It does not, and has not since MDL-WF04: a +// standalone `annotation` in a workflow body is refused at check time AND by +// execCreateWorkflow, because Mendix constructs every child of the activity flow +// with a Flow parent and no annotation type takes one — the written unit cannot +// be LOADED, so Studio Pro will not open the project. The describer was emitting +// the one construct the writer refuses, and a 23-activity workflow produced 13 +// MDL-WF04 errors from unmodified DESCRIBE output (mendixlabs/mxcli#1007). +// +// A comment is the honest emit today, not a workaround. The annotation being +// re-emitted here is ATTACHED to an activity, and although the write path stores +// an attached annotation (addActivityBaseFields), no MDL input can produce one: +// MDLWorkflow.g4 has only the standalone `workflowAnnotationStmt`. So the text is +// unwritable either way, and carrying it as a comment at least keeps it in front +// of whoever edits the script. The `annotation:` marker says what the line was. +// +// The microflow domain does have an attached form (`@annotation 'text'`, see +// MDLMicroflow.g4) and it round-trips properly. Giving workflow activities the +// same prefix is the fix that would preserve the annotation rather than +// commenting it out; it is a grammar change, and deliberately not bundled here. func formatAnnotation(annotation string, indent string) string { if annotation == "" { return "" } - return fmt.Sprintf("%sannotation %s;", indent, mdlQuoted(annotation)) + return annotationComment(annotation, indent) +} + +// annotationComment renders text as one or more `-- annotation:` lines. An +// annotation may contain newlines, and a `--` comment runs to end of line, so a +// multi-line note has to be prefixed line by line or everything after the first +// newline becomes stray tokens — the same failure the statement form had. +func annotationComment(text, indent string) string { + lines := strings.Split(text, "\n") + for i, l := range lines { + l = strings.TrimRight(l, "\r") + if i == 0 { + lines[i] = indent + "-- annotation: " + l + continue + } + lines[i] = indent + "-- " + l + } + return strings.Join(lines, "\n") } // boundaryEventKeyword maps an EventType string to the MDL BOUNDARY EVENT keyword sequence. @@ -360,12 +397,15 @@ func formatWorkflowActivities(flow *workflows.Flow, indent string) []string { // Skip - auto-generated by Mendix, implicit in MDL syntax continue case *workflows.WorkflowAnnotationActivity: - // Standalone annotation (sticky note) - emit as ANNOTATION statement - if a.Description != "" { - actLines = []string{fmt.Sprintf("%sannotation %s", indent, mdlQuoted(a.Description))} - } else { + // A standalone annotation (sticky note) read back from the model. Emitted + // as a comment for the same reason as an attached one: the `annotation` + // statement it used to produce is refused by MDL-WF04 and by exec, so the + // describe output could not be re-run (mendixlabs/mxcli#1007). + if a.Description == "" { continue } + isComment = true + actLines = []string{annotationComment(a.Description, indent)} case *workflows.GenericWorkflowActivity: isComment = true caption := a.Caption diff --git a/mdl/executor/issue1007_annotation_emit_test.go b/mdl/executor/issue1007_annotation_emit_test.go new file mode 100644 index 000000000..3ee58aea7 --- /dev/null +++ b/mdl/executor/issue1007_annotation_emit_test.go @@ -0,0 +1,140 @@ +// 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" +) + +// mendixlabs/mxcli#1007 — DESCRIBE WORKFLOW emitted `annotation '';`, the +// one construct MDL-WF04 exists to refuse. The describer and the validator +// disagreed, each stating its position in a comment, and the describer's was the +// stale one. +// +// The assertion is the full contract, not just "no annotation statement": the +// emitted MDL must PARSE and must pass ValidateWorkflow. Checking only that the +// keyword is gone would also pass for an emit that dropped the text entirely. + +// describeAndValidate emits the activities through the real describe path, wraps +// them in the smallest containing workflow, and returns the parse errors and the +// validator's rule IDs. +func describeAndValidate(t *testing.T, acts ...workflows.WorkflowActivity) (src string, parseErrs []string, ruleIDs []string) { + t.Helper() + lines := formatWorkflowActivities(&workflows.Flow{Activities: acts}, " ") + src = "create workflow M.WF\n parameter $WorkflowContext: M.E\nbegin\n" + + strings.Join(lines, "\n") + "\nend workflow;" + prog, errs := visitor.Build(src) + for _, e := range errs { + parseErrs = append(parseErrs, e.Error()) + } + if len(parseErrs) > 0 { + return src, parseErrs, nil + } + for _, stmt := range prog.Statements { + if wf, ok := stmt.(*ast.CreateWorkflowStmt); ok { + for _, v := range ValidateWorkflow(wf) { + ruleIDs = append(ruleIDs, v.RuleID) + } + } + } + return src, nil, ruleIDs +} + +// An annotation ATTACHED to an activity — the reporter's case. 13 of these came +// out of one 23-activity workflow. +func TestDescribeWorkflow_AttachedAnnotationPassesOwnCheck(t *testing.T) { + jump := &workflows.JumpToActivity{TargetActivity: "Review"} + jump.Name = "j1" + jump.Annotation = "Source: Receive + Set busState = New Opening" + + src, parseErrs, rules := describeAndValidate(t, jump) + if parseErrs != nil { + t.Fatalf("emitted MDL does not parse: %v\n%s", parseErrs, src) + } + for _, r := range rules { + if r == "MDL-WF04" { + t.Errorf("describe output still trips MDL-WF04:\n%s", src) + } + } + // The text must survive as something a reader can see — dropping it silently + // would satisfy the two checks above. + if !strings.Contains(src, "Source: Receive + Set busState = New Opening") { + t.Errorf("the annotation text was dropped:\n%s", src) + } + if strings.Contains(src, "annotation '") { + t.Errorf("still emitting an `annotation` statement:\n%s", src) + } +} + +// A standalone annotation (a canvas sticky note) read back from the model, which +// goes through a different branch of formatWorkflowActivities and had the same +// bug. Note the terminator: the branch must mark itself a comment, or `;` gets +// appended to the last line. +func TestDescribeWorkflow_StandaloneAnnotationPassesOwnCheck(t *testing.T) { + ann := &workflows.WorkflowAnnotationActivity{Description: "sticky note on the canvas"} + + src, parseErrs, rules := describeAndValidate(t, ann) + if parseErrs != nil { + t.Fatalf("emitted MDL does not parse: %v\n%s", parseErrs, src) + } + for _, r := range rules { + if r == "MDL-WF04" { + t.Errorf("describe output still trips MDL-WF04:\n%s", src) + } + } + if !strings.Contains(src, "sticky note on the canvas") { + t.Errorf("the annotation text was dropped:\n%s", src) + } + if strings.Contains(src, "; --") || strings.Contains(src, "note;") { + t.Errorf("a terminator was appended to a comment line:\n%s", src) + } +} + +// An annotation may contain newlines, and `--` runs to end of line — so every +// line needs its own prefix or the tail becomes stray tokens, which is the same +// failure mode the statement form had. +func TestDescribeWorkflow_MultiLineAnnotationCommentsEveryLine(t *testing.T) { + jump := &workflows.JumpToActivity{TargetActivity: "Review"} + jump.Name = "j1" + jump.Annotation = "first line\nsecond line\nthird line" + + src, parseErrs, _ := describeAndValidate(t, jump) + if parseErrs != nil { + t.Fatalf("multi-line annotation does not parse: %v\n%s", parseErrs, src) + } + for _, want := range []string{"first line", "second line", "third line"} { + if !strings.Contains(src, want) { + t.Errorf("%q missing from:\n%s", want, src) + } + } + for _, line := range strings.Split(src, "\n") { + for _, part := range []string{"second line", "third line"} { + if strings.Contains(line, part) && !strings.Contains(line, "--") { + t.Errorf("continuation line is not commented: %q", line) + } + } + } +} + +// The control for the whole change: an activity with no annotation must emit +// exactly what it did before, with no stray comment line. +func TestDescribeWorkflow_NoAnnotationEmitsNoComment(t *testing.T) { + jump := &workflows.JumpToActivity{TargetActivity: "Review"} + jump.Name = "j1" + + src, parseErrs, rules := describeAndValidate(t, jump) + if parseErrs != nil { + t.Fatalf("parse: %v\n%s", parseErrs, src) + } + if len(rules) > 0 { + t.Errorf("unexpected violations %v for a plain jump:\n%s", rules, src) + } + if strings.Contains(src, "annotation") { + t.Errorf("emitted an annotation for an activity that has none:\n%s", src) + } +} From 825873d688b697925217a9d3ba061a92ff80e069 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 31 Aug 2026 19:01:57 +0000 Subject: [PATCH 09/35] fix(workflow): stop naming a jump activity after its target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildJumpTo set the jump's Name to its target's name. Mendix resolves TargetActivity BY NAME, so the jump could resolve to itself; the build then fails CE6681 ("not possible to jump to end activities or jump-to activities"), which describes a different fault than the real one. Nothing caught it first. A jump target is the only INTRA-document reference a workflow has: validateWorkflowStatementRefs resolves microflows, pages and entities, and never looks at activity names. So `check --references` passed, `exec` reported success, and the defect surfaced only under the native validator. The reported trigger is an unresolved target. There is a second case, not in the report: a FORWARD jump to a target that DOES exist breaks the same way, because deduplication renames the second activity carrying a name. Measured: backward (target first) jump -> StepB2, target keeps StepB worked forward (jump first) jump KEEPS StepB, target -> StepB2 broke which is why loops looked fine and the reported table says a valid target is safe. Fixing only the unresolved case would have left half the bug in place. Both ends are fixed. A jump is named JumpTo (deduped: JumpTo, JumpTo2, ...) so it can never carry a target's name, and deduplication now runs in two passes with jumps LAST, so a jump never claims a name a real activity wanted whatever it is called. MDL-WF05 refuses an unresolved target at check time and, via the same function, in execCreateWorkflow — exec is reachable without check (the #833 lesson). The valid-target set comes from running the real builders over the AST rather than re-deriving names, because a `call microflow M.SUB_X` activity is named SUB_X and a second copy of that rule would drift into false refusals. The suggestion lists the valid names: they are not visible in the script, which is most of why the wrong one is easy to reach. Reported as mendixlabs/mxcli#1005. Co-Authored-By: Claude Opus 5 --- .claude/skills/fix-issue.md | 1 + mdl/executor/cmd_workflows_write.go | 103 ++++++-- mdl/executor/issue1005_jump_target_test.go | 278 +++++++++++++++++++++ mdl/executor/validate_workflow.go | 2 + mdl/executor/validate_workflow_jump.go | 167 +++++++++++++ 5 files changed, 529 insertions(+), 22 deletions(-) create mode 100644 mdl/executor/issue1005_jump_target_test.go create mode 100644 mdl/executor/validate_workflow_jump.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 021278b06..11d81a4bd 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -758,3 +758,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `mxcli run --local`/`--hub` sits at several hundred percent CPU for hours while the app is gone; `ps -o stat` shows the runtime JVM as `Z` (zombie) under it, `curl localhost:` returns `000`, and the hub preview URL still answers | `cmd/mxcli/docker/localboot.go` (`watchExit`, `alive`, `stopProcess`), `cmd/mxcli/docker/runlocal.go` (`waitForInterruptOrExit`, `runtimeStoppedError`) | Two mechanisms. (1) **Nothing ever called `Wait()`** on the runtime process, so an exited JVM stayed an unreaped zombie — and `alive()` asked `Signal(0)`, which **succeeds on a zombie**. Measured (Linux 6.18, go1.26): proc state `Z`, `Signal(0)` → nil; after `Wait()` → "process already finished". So the liveness check reported a runtime that had terminated itself hours earlier as alive. `Signal(0)` is only a correct liveness test *because* something reaps — removing `watchExit` silently breaks that line, which is why the control is on the reaper, not on `alive()`. (2) **After boot, `run` waited on a signal and nothing else**, so a correct answer had no one asking; it now waits on the signal OR `rt.Exited()` and returns a **non-zero** error, because returning 0 after the app has gone is what let a supervisor conclude all was well. **Why it happens at all**: the local standalone runtime uses a development licence with a maximum run time and terminates *itself* (measured lifetimes 3h52m and 5h07m — not a fixed number, and shorter than a working session); `runtimeExitReason` lifts that from the runtime's own log, and reports nothing rather than guessing when it cannot tell. **Two waiters on one process deadlock**, so `stopProcess` consults the reaper's channel instead of taking its own `Wait`. The CPU spin itself was NOT reproduced and is not claimed fixed by name — it lived in the tunnel client, under a supervisor blind to its dead child; what is fixed is the state it occurred in, since mxcli now exits and takes the tunnel with it. **Generalisable**: a 200 from a tunnelled URL is not evidence the app is alive — the tunnel outlives the runtime. Reported as mxcli-formula1 FINDINGS §60 | | After `mxcli test … --local`, an app another `mxcli run --local` is serving goes blank while still answering HTTP 200 (~1.7 KB, the Mendix SPA shell); the runtime log shows `Connector: 404 - file not found for file: dist%2Findex.js` and `deployment/web/dist` is gone | `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go` (`localTestDeployDir`), `cmd/mxcli/testrunner/runner.go` (`checkScratchDeploymentExists`) | A local test run already used its own ports and its own `_test` database — the code comment says why, verbatim — but shared the **deployment directory**, which is the one the *browser* reads. A headless test boot does not bundle the web client, so its build left the running app serving the shell over a 404: tests pass, run keeps running, app is blank, nothing reported at either end. **Detection was not the fix**: the two processes use different ports by design, so no port check can see it, and a lock file would only turn a silent blanking into a refusal. The test boot now builds into `/.mxcli/deployment-test/` — gitignored, already where the test runtime log lives — which makes the collision impossible. Note booting a runtime against the shared directory damages it even **without** a rebuild (the packaging step removes the bundle — FINDINGS §35, `ReportLostWebClientBundle`), so "reuse the dev loop's tree read-only" is not an alternative. Consequence to wire: `--skip-build` used to mean "reuse deployment/" and now has nothing until tests have run once, so it is refused with the reason rather than failing inside the runtime boot against a path the user never chose. Reported as mxcli-formula1 FINDINGS §62 | | A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — "absence from the documentation is not absence from the grammar" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → "widget *pages.X not yet supported by the modelsdk engine"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 | +| `mxcli check --references` and `exec` both pass, then the native `mx check` fails **CE6681** "It is not possible to jump to end activities or jump-to activities" on a workflow `jump to` | `buildJumpTo` set the jump activity's **`Name` to its target's name**. Mendix resolves `TargetActivity` **by name**, so the jump could resolve to itself — and CE6681 then describes a different fault than the real one (an unresolved target, or a stolen name) | `mdl/executor/cmd_workflows_write.go` (`buildJumpTo`, `deduplicateActivityNames`), `mdl/executor/validate_workflow_jump.go` (MDL-WF05) | **A jump target is the only INTRA-document reference a workflow has, and nothing resolved it** — `validateWorkflowStatementRefs` covers external refs (microflows, pages, entities) and returns before looking at activity names. Two defects, and the second is not in the report: naming the jump after its target also breaks a **forward** jump to a *valid* activity, because deduplication renames the SECOND activity carrying a name — measured, `jump ... then StepB` gives the jump `StepB` and the real target `StepB2`. The reported "works" row of the trigger table holds only for a backward jump, which is why loops looked fine. So fixing only the unresolved case would have left the other half. Fix both ends: give the jump its own name (`JumpTo`, deduped) so it can never collide, **and** run deduplication in two passes with jumps LAST so a jump never claims a name a real activity wanted. Wire the rule into `ValidateWorkflow` **and** call the same function from `execCreateWorkflow` (the #833 lesson — exec is reachable without check). Compute the valid-target set by running the real builders over the AST rather than re-deriving names: a `call microflow M.SUB_X` activity is named `SUB_X`, and a second copy of that rule would drift into false refusals. List the valid names in the suggestion — they are not visible in the script, which is most of why the wrong one is easy to reach. Exclude jumps from the target set (CE6681 again). Two traps hit while doing it: a nested `if jumpPass { if !jumpPass {` from a careless bulk edit silently disabled all jump renaming (caught only by a two-jump uniqueness test — CE0495 would have been the next report), and `Inner` is an MDL keyword, so a test fixture using it fails to parse for reasons unrelated to the fix. mendixlabs/mxcli#1005 | diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index afc53a8ea..5313e8e61 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -36,6 +36,19 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { "remove it, or keep the note as an MDL comment (`-- ...`) [MDL-WF04]") } + // Same for a jump whose target names no activity. A jump target is the only + // INTRA-document reference a workflow has, and validateWorkflowStatementRefs + // resolves only external ones (microflows, pages, entities), so nothing + // looked at it: the jump was written pointing at itself and surfaced under + // the native validator as CE6681, an error describing a different fault + // (mendixlabs/mxcli#1005). Refused with the check-time rule's own function so + // the two cannot drift, and here as well as at check time for the #833 + // reason — exec is reachable without check. + if vs := ValidateWorkflowJumpTargets(s); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s\n → %s", + s.Name.String(), vs[0].Message, vs[0].Suggestion) + } + // Refuse a broken reference here as well as at check time. `check // --references` reports these, but exec runs a different pass and wrote the // workflow anyway, so a script that skipped check produced a model the build @@ -494,15 +507,32 @@ func buildParallelSplit(n *ast.WorkflowParallelSplitNode) *workflows.ParallelSpl return act } +// jumpActivityName is the base name every jump activity gets; deduplication +// appends a counter, giving JumpTo, JumpTo2, ... +// +// It used to be the TARGET's name, which made the jump a second activity +// carrying that name. Mendix resolves TargetActivity by name, so the jump could +// resolve to itself — the build then fails CE6681 ("not possible to jump to end +// activities or jump-to activities"), an error naming a different fault +// (mendixlabs/mxcli#1005). Whether it did depended on flow order, because +// deduplication renames the SECOND activity it meets with a given name: +// +// backward jump (target first) jump becomes StepB2, target keeps StepB — worked +// forward jump (jump first) jump KEEPS StepB, target becomes StepB2 — broke +// +// so a jump to a perfectly valid activity was also affected, which is why fixing +// only the unresolved-target case would not have been enough. +const jumpActivityName = "JumpTo" + func buildJumpTo(n *ast.WorkflowJumpToNode) *workflows.JumpToActivity { act := &workflows.JumpToActivity{} act.ID = model.ID(generateWorkflowUUID()) - act.Name = n.Target + act.Name = jumpActivityName act.Caption = n.Caption act.TargetActivity = n.Target if act.Caption == "" { - act.Caption = act.Name + act.Caption = n.Target } return act @@ -556,73 +586,102 @@ func buildEndWorkflow(n *ast.WorkflowEndNode) *workflows.EndWorkflowActivity { // Mendix Studio Pro requires unique activity names (CE0495). func deduplicateActivityNames(activities []workflows.WorkflowActivity) { nameCount := make(map[string]int) - deduplicateActivityNamesInFlow(activities, nameCount) + // Two passes, jumps LAST. + // + // A jump is not a jump target (Mendix refuses that, CE6681), so it has no + // claim on a name a real activity wants. Letting it compete in flow order is + // how a FORWARD jump used to take its target's name and push the target to + // 2 — leaving the jump pointing at itself even though the target + // existed (mendixlabs/mxcli#1005). Naming jumps last means only jumps are + // ever suffixed, whatever they are called and wherever they appear. + deduplicateActivityNamesInFlow(activities, nameCount, false) + deduplicateActivityNamesInFlow(activities, nameCount, true) } -// deduplicateActivityNamesInFlow recursively deduplicates activity names. -func deduplicateActivityNamesInFlow(activities []workflows.WorkflowActivity, nameCount map[string]int) { +// deduplicateActivityNamesInFlow recursively deduplicates activity names. Both +// passes walk the whole tree; jumpPass selects which activities are renamed, so +// a jump nested in an outcome flow is still reached in the second pass. +func deduplicateActivityNamesInFlow(activities []workflows.WorkflowActivity, nameCount map[string]int, jumpPass bool) { for _, act := range activities { switch a := act.(type) { case *workflows.UserTask: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } for _, outcome := range a.Outcomes { if outcome.Flow != nil { - deduplicateActivityNamesInFlow(outcome.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(outcome.Flow.Activities, nameCount, jumpPass) } } case *workflows.CallMicroflowTask: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } for _, outcome := range a.Outcomes { switch o := outcome.(type) { case *workflows.BooleanConditionOutcome: if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) } case *workflows.EnumerationValueConditionOutcome: if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) } case *workflows.VoidConditionOutcome: if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) } } } case *workflows.CallWorkflowActivity: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } case *workflows.ExclusiveSplitActivity: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } for _, outcome := range a.Outcomes { switch o := outcome.(type) { case *workflows.BooleanConditionOutcome: if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) } case *workflows.EnumerationValueConditionOutcome: if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) } case *workflows.VoidConditionOutcome: if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) } } } case *workflows.ParallelSplitActivity: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } for _, outcome := range a.Outcomes { if outcome.Flow != nil { - deduplicateActivityNamesInFlow(outcome.Flow.Activities, nameCount) + deduplicateActivityNamesInFlow(outcome.Flow.Activities, nameCount, jumpPass) } } case *workflows.JumpToActivity: - a.Name = uniqueName(a.Name, nameCount) + if jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } case *workflows.WaitForTimerActivity: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } case *workflows.WaitForNotificationActivity: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } case *workflows.EndWorkflowActivity: - a.Name = uniqueName(a.Name, nameCount) + if !jumpPass { + a.Name = uniqueName(a.Name, nameCount) + } } } } diff --git a/mdl/executor/issue1005_jump_target_test.go b/mdl/executor/issue1005_jump_target_test.go new file mode 100644 index 000000000..ea13910bf --- /dev/null +++ b/mdl/executor/issue1005_jump_target_test.go @@ -0,0 +1,278 @@ +// 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" +) + +// mendixlabs/mxcli#1005 — a `jump to` whose target names no activity was +// silently written as a jump to ITSELF, because buildJumpTo named the jump after +// its target. Mendix resolves TargetActivity by name, so the only activity +// carrying the missing name was the jump; the build then failed CE6681 ("not +// possible to jump to end activities or jump-to activities"), naming a different +// fault entirely. + +func buildWorkflowFrom(t *testing.T, src string) []workflows.WorkflowActivity { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + wf, ok := prog.Statements[0].(*ast.CreateWorkflowStmt) + if !ok { + t.Fatalf("statement is %T, want *ast.CreateWorkflowStmt", prog.Statements[0]) + } + acts := buildWorkflowActivities(wf.Activities) + deduplicateActivityNames(acts) + return acts +} + +// findJumpAndNames returns the single jump in the tree and every non-jump name. +func findJumpAndNames(acts []workflows.WorkflowActivity) (*workflows.JumpToActivity, map[string]bool) { + names := map[string]bool{} + var jump *workflows.JumpToActivity + var walk func([]workflows.WorkflowActivity) + walk = func(list []workflows.WorkflowActivity) { + for _, a := range list { + switch v := a.(type) { + case *workflows.JumpToActivity: + jump = v + case *workflows.CallMicroflowTask: + names[v.Name] = true + for _, o := range v.Outcomes { + switch oo := o.(type) { + case *workflows.BooleanConditionOutcome: + if oo.Flow != nil { + walk(oo.Flow.Activities) + } + case *workflows.VoidConditionOutcome: + if oo.Flow != nil { + walk(oo.Flow.Activities) + } + } + } + case *workflows.UserTask: + names[v.Name] = true + for _, o := range v.Outcomes { + if o.Flow != nil { + walk(o.Flow.Activities) + } + } + } + } + } + walk(acts) + return jump, names +} + +const jumpBackward = `create workflow M.WF + parameter $WorkflowContext: M.E +begin + call microflow M.StepA with (P = '$WorkflowContext') + outcomes DEFAULT -> { }; + call microflow M.StepB with (P = '$WorkflowContext') + outcomes + true -> { } + false -> { jump to StepB comment 'retry'; }; +end workflow;` + +// The jump appears BEFORE its target. This is the case the report does not +// cover: its trigger table says a jump to a real activity is fine, and that +// holds only for a backward jump. Deduplication renames the SECOND activity it +// meets, so in flow order the jump took StepB and the real StepB became StepB2. +const jumpForward = `create workflow M.WF + parameter $WorkflowContext: M.E +begin + call microflow M.StepA with (P = '$WorkflowContext') + outcomes + true -> { } + false -> { jump to StepB comment 'skip ahead'; }; + call microflow M.StepB with (P = '$WorkflowContext') + outcomes DEFAULT -> { }; +end workflow;` + +func TestJumpTo_NeverTakesTheTargetsName(t *testing.T) { + for name, src := range map[string]string{"backward": jumpBackward, "forward": jumpForward} { + t.Run(name, func(t *testing.T) { + jump, names := findJumpAndNames(buildWorkflowFrom(t, src)) + if jump == nil { + t.Fatal("no jump activity was built") + } + if jump.Name == jump.TargetActivity { + t.Errorf("jump is named after its target (%q) — it targets itself", jump.Name) + } + if !names[jump.TargetActivity] { + t.Errorf("target %q is not the name of any activity; names present: %v", + jump.TargetActivity, names) + } + if jump.TargetActivity != "StepB" { + t.Errorf("TargetActivity = %q, want StepB", jump.TargetActivity) + } + }) + } +} + +// The caption a user wrote must survive the renaming — it is the only thing +// distinguishing two jumps in Studio Pro. +func TestJumpTo_KeepsTheAuthoredCaption(t *testing.T) { + jump, _ := findJumpAndNames(buildWorkflowFrom(t, jumpBackward)) + if jump.Caption != "retry" { + t.Errorf("Caption = %q, want %q", jump.Caption, "retry") + } +} + +// Two jumps in one workflow must still get distinct names. +func TestJumpTo_TwoJumpsGetDistinctNames(t *testing.T) { + src := `create workflow M.WF + parameter $WorkflowContext: M.E +begin + call microflow M.StepA with (P = '$WorkflowContext') + outcomes + true -> { jump to StepA comment 'again'; } + false -> { jump to StepA comment 'and again'; }; +end workflow;` + acts := buildWorkflowFrom(t, src) + seen := map[string]int{} + var walk func([]workflows.WorkflowActivity) + walk = func(list []workflows.WorkflowActivity) { + for _, a := range list { + switch v := a.(type) { + case *workflows.JumpToActivity: + seen[v.Name]++ + case *workflows.CallMicroflowTask: + seen[v.Name]++ + for _, o := range v.Outcomes { + if b, ok := o.(*workflows.BooleanConditionOutcome); ok && b.Flow != nil { + walk(b.Flow.Activities) + } + } + } + } + } + walk(acts) + for name, n := range seen { + if n > 1 { + t.Errorf("name %q used %d times — CE0495", name, n) + } + } +} + +func workflowRuleIDs(t *testing.T, src string) []string { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var out []string + for _, stmt := range prog.Statements { + if wf, ok := stmt.(*ast.CreateWorkflowStmt); ok { + for _, v := range ValidateWorkflow(wf) { + out = append(out, v.RuleID) + } + } + } + return out +} + +// MDL-WF05 — the check that did not exist. A jump target is the only +// intra-document reference a workflow has, and nothing resolved it. +func TestMDLWF05_FlagsADanglingJumpTarget(t *testing.T) { + src := `create workflow M.WF + parameter $WorkflowContext: M.E +begin + call microflow M.SUB_CheckPackageAvailability with (P = '$WorkflowContext') + outcomes + true -> { } + false -> { jump to callMicroflow2 comment 'back to StepB'; }; +end workflow;` + ids := workflowRuleIDs(t, src) + found := false + for _, id := range ids { + if id == "MDL-WF05" { + found = true + } + } + if !found { + t.Fatalf("no MDL-WF05 for an unresolved jump target; got %v", ids) + } + + // The valid names are not obvious from the script — a `call microflow + // M.SUB_CheckPackageAvailability` activity is named SUB_CheckPackageAvailability, + // which is most of why the wrong name is easy to reach — so the rule must + // list them. + prog, _ := visitor.Build(src) + wf := prog.Statements[0].(*ast.CreateWorkflowStmt) + vs := ValidateWorkflowJumpTargets(wf) + if len(vs) != 1 { + t.Fatalf("got %d violations, want 1", len(vs)) + } + if !strings.Contains(vs[0].Suggestion, "SUB_CheckPackageAvailability") { + t.Errorf("suggestion does not list the valid target: %q", vs[0].Suggestion) + } + if !strings.Contains(vs[0].Message, "CE6681") { + t.Errorf("message does not name the build error it prevents: %q", vs[0].Message) + } +} + +// The control: a jump to a real activity must NOT be flagged, in both +// directions. Without this the rule could pass by refusing every jump. +func TestMDLWF05_AcceptsAValidJumpTarget(t *testing.T) { + for name, src := range map[string]string{"backward": jumpBackward, "forward": jumpForward} { + t.Run(name, func(t *testing.T) { + for _, id := range workflowRuleIDs(t, src) { + if id == "MDL-WF05" { + t.Errorf("MDL-WF05 fired on a valid %s jump", name) + } + } + }) + } +} + +// A target inside a nested outcome flow is still a valid target — the collector +// has to recurse, or every jump into a branch would be refused. +func TestMDLWF05_FindsATargetInsideANestedFlow(t *testing.T) { + src := `create workflow M.WF + parameter $WorkflowContext: M.E +begin + call microflow M.Outer with (P = '$WorkflowContext') + outcomes + true -> { + call microflow M.InnerStep with (P = '$WorkflowContext') + outcomes DEFAULT -> { }; + } + false -> { jump to InnerStep comment 'into the branch'; }; +end workflow;` + for _, id := range workflowRuleIDs(t, src) { + if id == "MDL-WF05" { + t.Errorf("MDL-WF05 fired on a jump to an activity nested in an outcome flow") + } + } +} + +// A jump may not target another jump (CE6681), so a jump's own name must not be +// offered as a valid target. +func TestMDLWF05_AJumpIsNotAValidTarget(t *testing.T) { + src := `create workflow M.WF + parameter $WorkflowContext: M.E +begin + call microflow M.StepA with (P = '$WorkflowContext') + outcomes + true -> { jump to StepA comment 'one'; } + false -> { jump to JumpTo comment 'two'; }; +end workflow;` + found := false + for _, id := range workflowRuleIDs(t, src) { + if id == "MDL-WF05" { + found = true + } + } + if !found { + t.Error("a jump targeting another jump was accepted — Mendix rejects it with CE6681") + } +} diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index 33d211c9c..21a1abcf3 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -30,6 +30,7 @@ var wfOutcomeIdentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // - MDL-WF03: decision / call-microflow outcome that is not a valid // enumeration value identifier // - MDL-WF04: standalone `annotation` in a workflow body (unloadable model) +// - MDL-WF05: `jump to` a target that names no activity (see validate_workflow_jump.go) func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { var out []linter.Violation loc := linter.Location{ @@ -80,6 +81,7 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { }) } }) + out = append(out, ValidateWorkflowJumpTargets(stmt)...) return out } diff --git a/mdl/executor/validate_workflow_jump.go b/mdl/executor/validate_workflow_jump.go new file mode 100644 index 000000000..f4827f192 --- /dev/null +++ b/mdl/executor/validate_workflow_jump.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// jumpTargetRule is MDL-WF05: `jump to ` must name an activity in the +// same workflow. +// +// A jump target is the ONLY intra-document reference a workflow has. External +// references — microflows, pages, entities — are resolved twice (check +// --references, and again in execCreateWorkflow), but nothing looked at a jump, +// so an unresolved target was written out and surfaced only under the native +// validator as CE6681 "It is not possible to jump to end activities or jump-to +// activities" — an error describing a different fault, because Mendix resolves +// TargetActivity by NAME and the only activity carrying the missing name was the +// jump itself. See mendixlabs/mxcli#1005. +const jumpTargetRule = "MDL-WF05" + +// ValidateWorkflowJumpTargets reports every `jump to` whose target does not name +// an activity in the workflow. +// +// The candidate names are computed by running the real builders over the AST and +// reading the names off the result, not by re-deriving them here: a call +// microflow activity is named after the microflow it calls, a user task after +// its declared name, and a second copy of those rules would drift from the +// writer and produce exactly the false refusals this rule exists to prevent. +// None of it needs a project — every name comes from the script. +func ValidateWorkflowJumpTargets(stmt *ast.CreateWorkflowStmt) []linter.Violation { + if stmt == nil { + return nil + } + built := buildWorkflowActivities(stmt.Activities) + targets := map[string]bool{} + collectJumpableNames(built, targets) + + loc := linter.Location{ + Module: stmt.Name.Module, + DocumentType: "workflow", + DocumentName: stmt.Name.Name, + } + + var out []linter.Violation + walkWorkflowActivities(stmt.Activities, func(a ast.WorkflowActivityNode) { + n, ok := a.(*ast.WorkflowJumpToNode) + if !ok || n.Target == "" { + return + } + if targets[n.Target] { + return + } + out = append(out, linter.Violation{ + RuleID: jumpTargetRule, + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("jump target %q does not match any activity in this workflow — "+ + "Mendix resolves a jump by activity name, so this is written as a jump to itself and "+ + "the build fails with CE6681 (\"not possible to jump to end activities or jump-to activities\")", + n.Target), + Suggestion: jumpTargetSuggestion(targets), + }) + }) + return out +} + +// jumpTargetSuggestion lists what the author could have meant. The names are not +// obvious from the script — a `call microflow M.SUB_Check` activity is named +// SUB_Check, not by anything written at the call site — which is most of why the +// wrong name is easy to reach in the first place. +func jumpTargetSuggestion(targets map[string]bool) string { + if len(targets) == 0 { + return "This workflow has no activity a jump can target." + } + names := make([]string, 0, len(targets)) + for n := range targets { + names = append(names, n) + } + sort.Strings(names) + return "Valid targets in this workflow: " + strings.Join(names, ", ") + + ". A `call microflow` activity is named after the microflow it calls." +} + +// collectJumpableNames gathers the names of every activity a jump may target, +// recursing into outcome, path and boundary-event flows. +// +// Jump activities are deliberately excluded: Mendix refuses a jump to a jump +// (CE6681), so accepting one here would let the rule bless the exact model it +// exists to prevent. +func collectJumpableNames(acts []workflows.WorkflowActivity, out map[string]bool) { + add := func(name string) { + if name != "" { + out[name] = true + // autoBindWorkflowParameters sanitises some names before they are + // stored, so a jump written against either spelling resolves. + out[sanitizeActivityName(name)] = true + } + } + for _, act := range acts { + switch a := act.(type) { + case *workflows.UserTask: + add(a.Name) + for _, o := range a.Outcomes { + if o.Flow != nil { + collectJumpableNames(o.Flow.Activities, out) + } + } + collectBoundaryEventNames(a.BoundaryEvents, out) + case *workflows.CallMicroflowTask: + add(a.Name) + collectConditionOutcomeNames(a.Outcomes, out) + case *workflows.SystemTask: + add(a.Name) + case *workflows.CallWorkflowActivity: + add(a.Name) + case *workflows.ExclusiveSplitActivity: + add(a.Name) + collectConditionOutcomeNames(a.Outcomes, out) + case *workflows.ParallelSplitActivity: + add(a.Name) + for _, o := range a.Outcomes { + if o.Flow != nil { + collectJumpableNames(o.Flow.Activities, out) + } + } + case *workflows.WaitForTimerActivity: + add(a.Name) + case *workflows.WaitForNotificationActivity: + add(a.Name) + collectBoundaryEventNames(a.BoundaryEvents, out) + } + } +} + +func collectConditionOutcomeNames(outcomes []workflows.ConditionOutcome, out map[string]bool) { + for _, outcome := range outcomes { + switch o := outcome.(type) { + case *workflows.BooleanConditionOutcome: + if o.Flow != nil { + collectJumpableNames(o.Flow.Activities, out) + } + case *workflows.EnumerationValueConditionOutcome: + if o.Flow != nil { + collectJumpableNames(o.Flow.Activities, out) + } + case *workflows.VoidConditionOutcome: + if o.Flow != nil { + collectJumpableNames(o.Flow.Activities, out) + } + } + } +} + +func collectBoundaryEventNames(events []*workflows.BoundaryEvent, out map[string]bool) { + for _, e := range events { + if e != nil && e.Flow != nil { + collectJumpableNames(e.Flow.Activities, out) + } + } +} From 81efdab4d352bcab89f495ab21d00a92180fd95f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 19:16:26 +0000 Subject: [PATCH 10/35] fix(microflows): store an aggregate the way Studio Pro does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mendix keeps a Reduce's fold in two properties beside the expression — ReduceInitialValueExpression (the seed for $currentResult) and ReduceReturnDataType (what the fold produces). The semantic model had a field for neither, so both engines read them as nothing and wrote them back as nothing: rewriting a microflow containing a Reduce deleted the user's fold and left a model that still passed mx check. Both properties now round-trip through AggregateListAction and all four read/write paths. The shape is measured against Studio Pro, not inferred, and the reference documents contradict Mendix's own reference guide in two places: - Both keys are written on *every* AggregateAction, not only on Reduce. The All and Any activities each carry an empty initial value and a Boolean return type, though the guide says a return type is "not applicable" to them. So the pair is written for Reduce/All/Any, and for the five older functions only to carry back what a stored document already had — no reference document exists for those, and inventing a key is what produces a document mxbuild accepts and Studio Pro cannot open. - Attribute is stored as "" when unused. It was omitted, which made a freshly described Studio Pro aggregate rewrite on its first execution for no semantic reason. With all three, the Reduce/All/Any activities in ako/TestApp (Microflows.MicroflowReduce, Mendix 11.14) re-serialize byte-identically to what Studio Pro wrote. TestReduceFoldReachesStorage asserts on the stored BSON rather than the semantic model that produced it, because everything above this layer looked right while the document was wrong. Its control: revert the two setters and it reports both keys missing. Refs #1004 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG --- .../microflow_aggregate_reduce_write_test.go | 150 ++++++++++++++++++ .../modelsdk/microflow_read_actions.go | 5 + mdl/backend/modelsdk/microflow_write.go | 13 +- sdk/microflows/microflows_actions.go | 45 ++++++ sdk/mpr/parser_microflow.go | 9 ++ sdk/mpr/writer_microflow_actions.go | 15 +- 6 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 mdl/backend/modelsdk/microflow_aggregate_reduce_write_test.go diff --git a/mdl/backend/modelsdk/microflow_aggregate_reduce_write_test.go b/mdl/backend/modelsdk/microflow_aggregate_reduce_write_test.go new file mode 100644 index 000000000..2bfe35aa9 --- /dev/null +++ b/mdl/backend/modelsdk/microflow_aggregate_reduce_write_test.go @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/microflows" + "go.mongodb.org/mongo-driver/bson" +) + +// TestReduceFoldReachesStorage proves the half of #1004 that no amount of +// grammar work would fix: Mendix stores a Reduce's seed and result type in two +// properties the semantic model had no room for, so mxcli read them as nothing +// and wrote them back as nothing. The grammar gap made `reduce(...)` fail +// loudly; this one would have failed silently, deleting the user's fold from a +// microflow that still passed `mx check`. +// +// The expected values are measured, not assumed. Studio Pro writes both keys on +// every AggregateAction, not only on Reduce: in ako/TestApp's MicroflowReduce +// (Mendix 11.14) the All and Any activities each carry an empty initial value +// and a Boolean return type, even though Mendix's reference guide says a return +// type is "not applicable" to them. Attribute is likewise written as "" when +// unused, which is why it is asserted here rather than left absent. +func TestReduceFoldReachesStorage(t *testing.T) { + proj := copyFixture(t) + + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + + mf := µflows.Microflow{ + ContainerID: mod.ID, + Name: "ZZ_ReduceFold", + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + actionActivity(µflows.AggregateListAction{ + InputVariable: "CarList", + OutputVariable: "Total", + Function: microflows.AggregateFunctionReduce, + UseExpression: true, + Expression: "$currentResult + 1", + ReduceInitialValue: "0", + ReduceReturnType: µflows.DecimalType{}, + }), + actionActivity(µflows.AggregateListAction{ + InputVariable: "CarList", + OutputVariable: "AllMatch", + Function: microflows.AggregateFunctionAll, + UseExpression: true, + Expression: "true", + ReduceReturnType: µflows.BooleanType{}, + }), + }, + }, + } + if err := b.CreateMicroflow(mf); err != nil { + t.Fatalf("CreateMicroflow: %v", err) + } + if err := b.Disconnect(); err != nil { + t.Fatalf("disconnect: %v", err) + } + + got := collectAggregateProps(t, readUnitBytes(t, proj, string(mf.ID))) + if len(got) != 2 { + t.Fatalf("stored %d aggregate actions, want 2: %#v", len(got), got) + } + + want := []map[string]any{ + { + "AggregateFunction": "Reduce", + "ReduceInitialValueExpression": "0", + "Attribute": "", + }, + { + // ALL folds a Boolean and takes no seed, so the seed is stored empty + // rather than omitted — matching the reference document. + "AggregateFunction": "All", + "ReduceInitialValueExpression": "", + "Attribute": "", + }, + } + wantReturnType := []string{"DataTypes$DecimalType", "DataTypes$BooleanType"} + + for i, w := range want { + for k, v := range w { + stored, ok := got[i][k] + if !ok { + t.Errorf("aggregate %d (%s): %s missing from the stored document", i, w["AggregateFunction"], k) + continue + } + if stored != v { + t.Errorf("aggregate %d (%s): %s = %#v, want %#v", i, w["AggregateFunction"], k, stored, v) + } + } + rt, ok := got[i]["ReduceReturnDataType"].(bson.M) + if !ok { + t.Errorf("aggregate %d (%s): ReduceReturnDataType missing or not a document: %#v", + i, w["AggregateFunction"], got[i]["ReduceReturnDataType"]) + continue + } + typeName, _ := rt["$Type"].(string) + if typeName != wantReturnType[i] { + t.Errorf("aggregate %d (%s): return type = %q, want %q", + i, w["AggregateFunction"], typeName, wantReturnType[i]) + } + } +} + +// collectAggregateProps returns one flat map per stored aggregate activity, in +// document order. +func collectAggregateProps(t *testing.T, raw []byte) []map[string]any { + t.Helper() + + var doc bson.M + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal unit: %v", err) + } + + var out []map[string]any + var walk func(any) + walk = func(v any) { + switch n := v.(type) { + case bson.M: + if typ, _ := n["$Type"].(string); typ == "Microflows$AggregateAction" { + out = append(out, map[string]any(n)) + } + for _, child := range n { + walk(child) + } + case bson.D: + m := bson.M{} + for _, e := range n { + m[e.Key] = e.Value + } + walk(m) + case bson.A: + for _, child := range n { + walk(child) + } + } + } + walk(doc) + return out +} diff --git a/mdl/backend/modelsdk/microflow_read_actions.go b/mdl/backend/modelsdk/microflow_read_actions.go index 49409fb2c..4a20ce193 100644 --- a/mdl/backend/modelsdk/microflow_read_actions.go +++ b/mdl/backend/modelsdk/microflow_read_actions.go @@ -212,6 +212,11 @@ func actionFromGen(el element.Element) microflows.MicroflowAction { AttributeQualifiedName: a.AttributeQualifiedName(), UseExpression: a.UseExpression(), Expression: a.Expression(), + // Reduce's fold: what it starts from and what it folds to. Studio Pro + // stores both on every AggregateAction, so a rewrite that did not read + // them back silently deleted the fold (#1004). + ReduceInitialValue: a.ReduceInitialValueExpression(), + ReduceReturnType: dataTypeFromGen(a.ReduceReturnDataType()), } out.ID = model.ID(a.ID()) return out diff --git a/mdl/backend/modelsdk/microflow_write.go b/mdl/backend/modelsdk/microflow_write.go index 069ac0d84..20f639e0b 100644 --- a/mdl/backend/modelsdk/microflow_write.go +++ b/mdl/backend/modelsdk/microflow_write.go @@ -680,8 +680,17 @@ func microflowActionToGen(action microflows.MicroflowAction) element.Element { if a.UseExpression { g.SetUseExpression(true) g.SetExpression(a.Expression) - } else if a.AttributeQualifiedName != "" { - g.SetAttributeQualifiedName(a.AttributeQualifiedName) + } + // Written even when unused: every Studio Pro reference document carries + // Attribute as "". Omitting it made a freshly described Studio Pro + // aggregate rewrite on its first execution for no semantic reason. + g.SetAttributeQualifiedName(a.AttributeQualifiedName) + // Reduce's fold. Written for the functions a reference document shows + // Mendix storing them on, and otherwise only to carry back what the + // stored document already had (#1004). + if a.Function.WritesReduceProperties() || a.ReduceInitialValue != "" || a.ReduceReturnType != nil { + g.SetReduceInitialValueExpression(a.ReduceInitialValue) + g.SetReduceReturnDataType(microflowDataTypeToGen(a.ReduceReturnType)) } g.SetOutputVariableName(a.OutputVariable) return g diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index c5ef900f2..b8517f8ff 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -197,6 +197,16 @@ type AggregateListAction struct { AttributeQualifiedName string `json:"attributeQualifiedName,omitempty"` // BY_NAME_REFERENCE: Module.Entity.Attribute UseExpression bool `json:"useExpression,omitempty"` // true when Expression is used instead of Attribute Expression string `json:"expression,omitempty"` // Mendix expression string (when UseExpression=true) + + // ReduceInitialValue and ReduceReturnType are what REDUCE folds from, and + // the type it folds to. Studio Pro writes both on *every* AggregateAction it + // stores, not only on Reduce: measured on the three reference activities in + // ako/TestApp (Reduce, All, Any), where All and Any carry an empty initial + // value and a Boolean return type even though Mendix's own reference guide + // says a return type is "not applicable" to them. Carrying both here is what + // keeps a stored Reduce from losing its fold on rewrite (#1004). + ReduceInitialValue string `json:"reduceInitialValue,omitempty"` + ReduceReturnType DataType `json:"reduceReturnType,omitempty"` } func (AggregateListAction) isMicroflowAction() {} @@ -211,8 +221,43 @@ const ( AggregateFunctionMin AggregateFunction = "Minimum" AggregateFunctionMax AggregateFunction = "Maximum" AggregateFunctionReduce AggregateFunction = "Reduce" + AggregateFunctionAll AggregateFunction = "All" + AggregateFunctionAny AggregateFunction = "Any" ) +// AllAggregateFunctions is every value Mendix's AggregateFunction enumeration +// can hold. DESCRIBE used to render an aggregate by lowercasing whatever was +// stored, so each function Mendix added came back out as MDL the grammar could +// not read (#1004). Anything rendering or parsing these must cover this list. +var AllAggregateFunctions = []AggregateFunction{ + AggregateFunctionCount, + AggregateFunctionSum, + AggregateFunctionAverage, + AggregateFunctionMin, + AggregateFunctionMax, + AggregateFunctionReduce, + AggregateFunctionAll, + AggregateFunctionAny, +} + +// WritesReduceProperties reports whether Mendix stores +// ReduceInitialValueExpression and ReduceReturnDataType for this function. +// +// Measured true for Reduce, All and Any against Studio Pro reference documents +// (ako/TestApp): All and Any carry an empty initial value and a Boolean return +// type, so the two properties are not Reduce-only. No reference document exists +// for the five older functions, so mxcli writes the pair for those only when a +// stored document already carried it — inventing a key is the mistake that makes +// a document mxbuild accepts and Studio Pro cannot open. +func (f AggregateFunction) WritesReduceProperties() bool { + switch f { + case AggregateFunctionReduce, AggregateFunctionAll, AggregateFunctionAny: + return true + default: + return false + } +} + // ListOperationAction performs list operations. type ListOperationAction struct { model.BaseElement diff --git a/sdk/mpr/parser_microflow.go b/sdk/mpr/parser_microflow.go index 9a580550a..c61edee65 100644 --- a/sdk/mpr/parser_microflow.go +++ b/sdk/mpr/parser_microflow.go @@ -987,6 +987,15 @@ func parseAggregateListAction(raw map[string]any) *microflows.AggregateListActio action.Expression = extractString(raw["Expression"]) } + // Reduce's fold: what it starts from and what it folds to. Studio Pro writes + // both on every AggregateAction (empty initial value on All/Any), so read + // them unconditionally rather than only for Reduce — a rewrite that dropped + // them silently deleted the fold (#1004). + action.ReduceInitialValue = extractString(raw["ReduceInitialValueExpression"]) + if rt, ok := raw["ReduceReturnDataType"].(map[string]any); ok { + action.ReduceReturnType = parseMicroflowDataType(rt) + } + return action } diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go index 109baef52..b06dccff9 100644 --- a/sdk/mpr/writer_microflow_actions.go +++ b/sdk/mpr/writer_microflow_actions.go @@ -380,9 +380,18 @@ func serializeMicroflowAction(action microflows.MicroflowAction) bson.D { if a.UseExpression { doc = append(doc, bson.E{Key: "UseExpression", Value: true}) doc = append(doc, bson.E{Key: "Expression", Value: a.Expression}) - } else if a.AttributeQualifiedName != "" { - // Attribute is BY_NAME_REFERENCE - doc = append(doc, bson.E{Key: "Attribute", Value: a.AttributeQualifiedName}) + } + // Attribute is BY_NAME_REFERENCE, and is written even when unused: every + // Studio Pro reference document carries it as "". Omitting it made a + // freshly described Studio Pro aggregate rewrite on its first execution + // for no semantic reason. + doc = append(doc, bson.E{Key: "Attribute", Value: a.AttributeQualifiedName}) + // Reduce's fold. Written for the functions a reference document shows + // Mendix storing them on, and otherwise only to carry back what the + // stored document already had (#1004). + if a.Function.WritesReduceProperties() || a.ReduceInitialValue != "" || a.ReduceReturnType != nil { + doc = append(doc, bson.E{Key: "ReduceInitialValueExpression", Value: a.ReduceInitialValue}) + doc = append(doc, bson.E{Key: "ReduceReturnDataType", Value: serializeMicroflowDataType(a.ReduceReturnType)}) } doc = append(doc, bson.E{Key: "VariableName", Value: a.OutputVariable}) // storageName for outputVariableName return doc From a68ef1007238e46f462cf292ab688baea52f155c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 19:16:39 +0000 Subject: [PATCH 11/35] feat(mdl): reduce, all and any in the aggregate grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mendix has eight aggregate functions; listAggregateOperation had five. The three it lacked are the ones DESCRIBE was already emitting, so a Studio Pro-authored Reduce described as MDL that would not parse — it fell through to a plain Change Variable, and MDL044 then correctly reported that Mendix has no reduce() expression function. $Folded = reduce($list, expr, initial: seed, returns: Type); $AllMatch = all($list, boolean-expression); $AnyMatch = any($list, boolean-expression); REDUCE names its seed and result type because Mendix requires both and neither is inferable from the expression — guessing either is how the fold gets silently lost. ALL and ANY take neither: they never accumulate and always fold to Boolean, so writing a type would only let an author contradict Mendix. REDUCE, ANY and INITIAL are new lexer tokens, so they are also added to the `keyword` rule to stay usable as identifiers; ALL and RETURNS already existed. Reusing ALL in expression position introduces no ambiguity — ANTLR generates the parser without warnings. ast.AggregateReduce and its flowBuilder case already existed, unreachable, with no grammar rule and no visitor mapping behind them. The regenerated completions also pick up CUSTOMBUTTON and ALLOWEDFILEFORMAT, which have been missing since dbc26ffd added them to the lexer without regenerating the file. Refs #1004 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG --- cmd/mxcli/lsp_completions_gen.go | 3 + mdl/ast/ast_microflow.go | 15 +++- .../cmd_microflows_builder_actions.go | 19 +++++ mdl/grammar/MDLLexer.g4 | 4 + mdl/grammar/domains/MDLMicroflow.g4 | 14 ++++ mdl/grammar/domains/MDLSettings.g4 | 2 +- mdl/visitor/visitor_microflow_actions.go | 35 +++++++++ .../visitor_microflow_aggregate_test.go | 73 +++++++++++++++++++ 8 files changed, 163 insertions(+), 2 deletions(-) diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index 57a2a7574..f5b270159 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -157,6 +157,9 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "AVERAGE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "MINIMUM", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "MAXIMUM", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, + {Label: "REDUCE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, + {Label: "ANY", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, + {Label: "INITIAL", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "LIST", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "REMOVE", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, {Label: "EQUALS", Kind: protocol.CompletionItemKindKeyword, Detail: "Microflow keyword"}, diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 9c30dba8d..db6fde1a8 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -725,6 +725,8 @@ const ( AggregateMinimum AggregateMaximum AggregateReduce + AggregateAll + AggregateAny ) func (t AggregateListOperationType) String() string { @@ -741,6 +743,10 @@ func (t AggregateListOperationType) String() string { return "MAXIMUM" case AggregateReduce: return "REDUCE" + case AggregateAll: + return "ALL" + case AggregateAny: + return "ANY" default: return "UNKNOWN" } @@ -757,7 +763,14 @@ type AggregateListStmt struct { Attribute string // Attribute name for SUM/AVG/MIN/MAX (empty for COUNT or expression form) IsExpression bool // true when Expression is used instead of Attribute Expression Expression // Mendix expression (when IsExpression=true) - Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation + + // REDUCE only. InitialValue seeds $currentResult; ReturnType is the type the + // fold produces. Mendix requires both and neither can be inferred from the + // expression, so REDUCE names them and the other functions leave them zero. + InitialValue Expression + ReturnType *DataType + + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } func (s *AggregateListStmt) isMicroflowStatement() {} diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index d74d49a83..85415903a 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -1401,6 +1401,10 @@ func (fb *flowBuilder) addAggregateListAction(s *ast.AggregateListStmt) model.ID function = microflows.AggregateFunctionMax case ast.AggregateReduce: function = microflows.AggregateFunctionReduce + case ast.AggregateAll: + function = microflows.AggregateFunctionAll + case ast.AggregateAny: + function = microflows.AggregateFunctionAny default: return "" } @@ -1412,6 +1416,21 @@ func (fb *flowBuilder) addAggregateListAction(s *ast.AggregateListStmt) model.ID Function: function, } + // The fold Mendix stores beside the expression. REDUCE names both in MDL; + // ALL and ANY always fold a Boolean and never take a seed, which is what + // Studio Pro writes for them (empty initial value, Boolean return type). + switch s.Operation { + case ast.AggregateReduce: + if s.InitialValue != nil { + action.ReduceInitialValue = expressionToString(s.InitialValue) + } + if s.ReturnType != nil { + action.ReduceReturnType = convertASTToMicroflowDataType(*s.ReturnType, nil) + } + case ast.AggregateAll, ast.AggregateAny: + action.ReduceReturnType = µflows.BooleanType{} + } + if s.IsExpression && s.Expression != nil { action.UseExpression = true action.Expression = expressionToString(s.Expression) diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index e20d8af32..a13502983 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -217,6 +217,10 @@ CONTAINS: C O N T A I N S; AVERAGE: A V E R A G E; MINIMUM: M I N I M U M; MAXIMUM: M A X I M U M; +// Mendix's three newer aggregate functions. ALL is already a token above. +REDUCE: R E D U C E; +ANY: A N Y; +INITIAL: I N I T I A L; LIST: L I S T; REMOVE: R E M O V E; EQUALS_OP: E Q U A L S; diff --git a/mdl/grammar/domains/MDLMicroflow.g4 b/mdl/grammar/domains/MDLMicroflow.g4 index bdaea6b15..54667f399 100644 --- a/mdl/grammar/domains/MDLMicroflow.g4 +++ b/mdl/grammar/domains/MDLMicroflow.g4 @@ -828,6 +828,20 @@ listAggregateOperation | MINIMUM LPAREN attributePath RPAREN // $min = MINIMUM($list.attr) | MAXIMUM LPAREN VARIABLE COMMA expression RPAREN // $max = MAXIMUM($list, expr) | MAXIMUM LPAREN attributePath RPAREN // $max = MAXIMUM($list.attr) + // REDUCE folds a list into one value. Both extra inputs are required and + // neither is derivable: `initial` seeds $currentResult, `returns` is the + // type Mendix stores alongside it (#1004). + | REDUCE LPAREN VARIABLE COMMA expression COMMA reduceFoldOptions RPAREN // $total = REDUCE($list, expr, initial: 0, returns: Decimal) + // ALL / ANY test a Boolean expression over every item. Their return type is + // always Boolean, so it is derived rather than written. + | ALL LPAREN VARIABLE COMMA expression RPAREN // $allMatch = ALL($list, expr) + | ANY LPAREN VARIABLE COMMA expression RPAREN // $anyMatch = ANY($list, expr) + ; + +// REDUCE's seed and result type, in the ( key: value ) property style used +// across MDL. Order is fixed so the statement reads the way it is written. +reduceFoldOptions + : INITIAL COLON expression COMMA RETURNS COLON dataType ; /** diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 72c706862..c71af8aa4 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -581,7 +581,7 @@ keyword | COUNT | SUM | AVG | MIN | MAX | DISTINCT | ALL | ASC | DESC | UNION | INTERSECT | SUBTRACT | EXISTS | CAST | COALESCE | TRIM | LENGTH | CONTAINS | MATCH - | AVERAGE | MINIMUM | MAXIMUM + | AVERAGE | MINIMUM | MAXIMUM | REDUCE | ANY | INITIAL | IS_NULL | IS_NOT_NULL | NOT_NULL | HEAD | TAIL | FIND | SORT | EMPTY | LIST_OF | LIST_KW | EQUALS_OP diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index 2f8d4a6a8..63d81e40a 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -983,6 +983,41 @@ func buildAggregateListStatement(ctx parser.IAggregateListStatementContext) *ast } else if path := op.AttributePath(); path != nil { stmt.InputVariable, stmt.Attribute = parseAttributePath(path.GetText()) } + } else if op.REDUCE() != nil { + // REDUCE($list, expr, initial: seed, returns: Type). Expression(0) is + // the fold body; the seed lives inside reduceFoldOptions, which the + // grammar makes mandatory, so a well-formed parse always has both. + stmt.Operation = ast.AggregateReduce + stmt.IsExpression = true + if v := op.VARIABLE(); v != nil { + stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") + } + if exprCtx := op.Expression(); exprCtx != nil { + stmt.Expression = buildSourceExpression(exprCtx) + } + if opts, ok := op.ReduceFoldOptions().(*parser.ReduceFoldOptionsContext); ok && opts != nil { + if seed := opts.Expression(); seed != nil { + stmt.InitialValue = buildSourceExpression(seed) + } + if dt := opts.DataType(); dt != nil { + t := buildDataType(dt) + stmt.ReturnType = &t + } + } + } else if op.ALL() != nil || op.ANY() != nil { + // Boolean predicates over the list. No seed, and the return type is + // always Boolean, so neither is written in MDL. + stmt.Operation = ast.AggregateAll + if op.ANY() != nil { + stmt.Operation = ast.AggregateAny + } + stmt.IsExpression = true + if v := op.VARIABLE(); v != nil { + stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") + } + if exprCtx := op.Expression(); exprCtx != nil { + stmt.Expression = buildSourceExpression(exprCtx) + } } } diff --git a/mdl/visitor/visitor_microflow_aggregate_test.go b/mdl/visitor/visitor_microflow_aggregate_test.go index 7787af2d6..50f154f93 100644 --- a/mdl/visitor/visitor_microflow_aggregate_test.go +++ b/mdl/visitor/visitor_microflow_aggregate_test.go @@ -101,3 +101,76 @@ func parseSingleAggregate(t *testing.T, stmt string) *ast.AggregateListStmt { t.Fatalf("no AggregateListStmt produced by %q", stmt) return nil } + +// Mendix has eight aggregate functions; the grammar had five. DESCRIBE renders +// an activity by name, so a stored Reduce/All/Any came back out as MDL the +// parser could not read: `reduce(...)` fell through to a plain Change Variable +// and MDL044 then reported `reduce()` as "not a Mendix expression function" +// (#1004). Each of the three must reach an aggregate, not a SET. +func TestAggregateAcceptsReduceAllAny(t *testing.T) { + cases := []struct { + name, src string + wantOp ast.AggregateListOperationType + wantInit bool + wantReturn ast.DataTypeKind + }{ + { + name: "reduce carries its seed and result type", + src: "$T = reduce($ProductList, $currentResult + $currentObject/Price, initial: 0, returns: Decimal);", + wantOp: ast.AggregateReduce, + wantInit: true, + wantReturn: ast.TypeDecimal, + }, + { + name: "all takes a bare boolean expression", + src: "$T = all($ProductList, $currentObject/Price > 0);", + wantOp: ast.AggregateAll, + }, + { + name: "any takes a bare boolean expression", + src: "$T = any($ProductList, $currentObject/Price > 0);", + wantOp: ast.AggregateAny, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSingleAggregate(t, tc.src) + if got.Operation != tc.wantOp { + t.Errorf("operation = %v, want %v", got.Operation, tc.wantOp) + } + if got.InputVariable != "ProductList" { + t.Errorf("input variable = %q, want %q", got.InputVariable, "ProductList") + } + if !got.IsExpression || got.Expression == nil { + t.Errorf("expression form not recognised: IsExpression=%v Expression=%v", got.IsExpression, got.Expression) + } + if tc.wantInit { + if got.InitialValue == nil { + t.Error("initial value missing — reduce would be stored with an empty fold") + } + if got.ReturnType == nil { + t.Fatal("return type missing — reduce would be stored with no result type") + } + if got.ReturnType.Kind != tc.wantReturn { + t.Errorf("return type kind = %v, want %v", got.ReturnType.Kind, tc.wantReturn) + } + } else if got.InitialValue != nil || got.ReturnType != nil { + // ALL/ANY have no seed and always fold to Boolean; writing either + // from MDL would let an author contradict Mendix. + t.Errorf("%v should carry no seed or declared type, got initial=%v returns=%v", + tc.wantOp, got.InitialValue, got.ReturnType) + } + }) + } +} + +// Adding REDUCE, ANY and INITIAL as lexer tokens takes those words out of +// circulation as bare identifiers unless they are also listed in the `keyword` +// rule. They are, and this is the check that says so. +func TestReduceKeywordsStayUsableAsIdentifiers(t *testing.T) { + src := "create entity M.Thing (\n reduce: string(10),\n any: string(10),\n initial: string(10)\n);" + if _, errs := Build(src); len(errs) > 0 { + t.Fatalf("new keywords are no longer usable as attribute names: %v", errs) + } +} From 250d991063a3b3ba2b49d50025b15ffbc11596ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 19:16:53 +0000 Subject: [PATCH 12/35] fix(microflows): describe every aggregate as MDL that parses back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESCRIBE rendered an aggregate with strings.ToLower on whatever Mendix had stored, which silently assumed every value of the AggregateFunction enumeration was also an MDL keyword. Five were. Reduce, All and Any were not, so mxcli emitted MDL its own checker rejected (#1004) — and would keep doing so for every function Mendix adds. The mapping is now explicit, and a function with no keyword is reported as an unrenderable activity rather than emitted as a plausible-looking lie. REDUCE additionally renders its seed and result type, without which the described statement cannot be executed back. The sibling formatListOperation switches on concrete types and cannot drift this way; stringifying an enum into a keyword is the shape to avoid. TestDescribedAggregateParsesBack drives every function in microflows.AllAggregateFunctions through describe and back through the parser, so a ninth function fails a test rather than a user's script. Control on origin/main: reduce/all/any each parse to a Change Variable rather than an aggregate, with sum as the positive control. Closes #1004 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG --- ...cmd_microflows_aggregate_roundtrip_test.go | 112 ++++++++++++++++++ mdl/executor/cmd_microflows_format_action.go | 61 +++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 mdl/executor/cmd_microflows_aggregate_roundtrip_test.go diff --git a/mdl/executor/cmd_microflows_aggregate_roundtrip_test.go b/mdl/executor/cmd_microflows_aggregate_roundtrip_test.go new file mode 100644 index 000000000..cc77c1387 --- /dev/null +++ b/mdl/executor/cmd_microflows_aggregate_roundtrip_test.go @@ -0,0 +1,112 @@ +// 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/microflows" +) + +// aggregateOperationName maps the AST's aggregate kind back to the Mendix +// function it builds, so the round trip below can compare like with like. +var aggregateOperationName = map[ast.AggregateListOperationType]microflows.AggregateFunction{ + ast.AggregateCount: microflows.AggregateFunctionCount, + ast.AggregateSum: microflows.AggregateFunctionSum, + ast.AggregateAverage: microflows.AggregateFunctionAverage, + ast.AggregateMinimum: microflows.AggregateFunctionMin, + ast.AggregateMaximum: microflows.AggregateFunctionMax, + ast.AggregateReduce: microflows.AggregateFunctionReduce, + ast.AggregateAll: microflows.AggregateFunctionAll, + ast.AggregateAny: microflows.AggregateFunctionAny, +} + +// TestDescribedAggregateParsesBack is the test #1004 needed and did not have. +// +// DESCRIBE rendered an aggregate by lowercasing whatever Mendix had stored, +// which silently assumed every value of the AggregateFunction enumeration was +// also an MDL keyword. Five were; Reduce, All and Any were not, so a Studio +// Pro-authored activity described as MDL that mxcli's own checker rejected +// ("'reduce()' ... is not a Mendix expression function [MDL044]"). +// +// Driving every function through describe and back through the parser is what +// makes that class of gap impossible to reintroduce: a ninth function added to +// Mendix fails here rather than in a user's script. +func TestDescribedAggregateParsesBack(t *testing.T) { + for _, fn := range microflows.AllAggregateFunctions { + t.Run(string(fn), func(t *testing.T) { + action := µflows.AggregateListAction{ + InputVariable: "ProductList", + OutputVariable: "Result", + Function: fn, + UseExpression: true, + Expression: "$currentObject/Price", + } + // Reduce is the one function whose fold is not derivable, so a + // describe that omits it is lossy even if it parses. + if fn == microflows.AggregateFunctionReduce { + action.ReduceInitialValue = "0" + action.ReduceReturnType = µflows.DecimalType{} + } + + rendered := formatAction(nil, action, nil, nil) + if strings.HasPrefix(strings.TrimSpace(rendered), "//") { + t.Fatalf("%s has no MDL keyword — DESCRIBE cannot render it: %s", fn, rendered) + } + + src := "create microflow M.A ($ProductList: list of M.Product)\nbegin\n " + rendered + "\nend;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("DESCRIBE emitted MDL the parser rejects.\n rendered: %s\n errors: %v", rendered, errs) + } + + agg := findAggregate(t, prog, rendered) + if got := aggregateOperationName[agg.Operation]; got != fn { + t.Errorf("round trip changed the function: %s -> %s (rendered %q)", fn, got, rendered) + } + if fn == microflows.AggregateFunctionReduce { + if agg.InitialValue == nil || agg.ReturnType == nil { + t.Errorf("reduce lost its fold on the way back: initial=%v returns=%v (rendered %q)", + agg.InitialValue, agg.ReturnType, rendered) + } + } + }) + } +} + +// TestAggregateKeywordsCoverEveryFunction guards the mapping itself, so a +// function added to microflows.AllAggregateFunctions without an MDL keyword +// fails here with a clear reason rather than as a parse error above. +func TestAggregateKeywordsCoverEveryFunction(t *testing.T) { + for _, fn := range microflows.AllAggregateFunctions { + if _, ok := mdlAggregateKeyword(fn); !ok { + t.Errorf("aggregate function %q has no MDL keyword — DESCRIBE would refuse to render it", fn) + } + } +} + +// findAggregate returns the single aggregate in a parsed microflow, failing +// with the statement kind that was produced instead. `reduce(...)` used to +// parse as a Change Variable, which is exactly how #1004 presented. +func findAggregate(t *testing.T, prog *ast.Program, rendered string) *ast.AggregateListStmt { + t.Helper() + for _, s := range prog.Statements { + cm, ok := s.(*ast.CreateMicroflowStmt) + if !ok { + continue + } + for _, st := range cm.Body { + if agg, ok := st.(*ast.AggregateListStmt); ok { + return agg + } + if set, ok := st.(*ast.MfSetStmt); ok { + t.Fatalf("%q parsed as a Change Variable of $%s, not an aggregate", rendered, set.Target) + } + } + } + t.Fatalf("no aggregate produced by %q", rendered) + return nil +} diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index 8c4bb1a4b..e6ca3518b 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -409,9 +409,12 @@ func formatAction( if outputVar == "" { outputVar = "Result" } - fn := string(a.Function) - if fn == "" { - fn = "count" + fn, known := mdlAggregateKeyword(a.Function) + if !known { + // A function this build has no MDL keyword for. Rendering it by + // lowercasing the stored name is what produced unreadable MDL for + // Reduce/All/Any (#1004), so say so instead of emitting a lie. + return fmt.Sprintf("// unsupported aggregate function %q on $%s — mxcli cannot render this activity as MDL;", a.Function, outputVar) } // Extract attribute name (use last part of qualified name for readability) attrName := a.AttributeQualifiedName @@ -422,15 +425,27 @@ func formatAction( attrName = parts[len(parts)-1] } } + // REDUCE carries the fold Mendix stores beside the expression. Both parts + // are required, so they are rendered even when empty rather than dropped — + // a reduce that describes without them cannot be executed back (#1004). + if a.Function == microflows.AggregateFunctionReduce { + initial := a.ReduceInitialValue + if initial == "" { + initial = "empty" + } + return fmt.Sprintf("$%s = reduce($%s, %s, initial: %s, returns: %s);", + outputVar, a.InputVariable, a.Expression, initial, + formatMicroflowDataType(ctx, a.ReduceReturnType, entityNames)) + } // Expression-based aggregate: SUM($list, $currentObject/Attr + 1) if a.UseExpression && a.Expression != "" { - return fmt.Sprintf("$%s = %s($%s, %s);", outputVar, strings.ToLower(fn), a.InputVariable, a.Expression) + return fmt.Sprintf("$%s = %s($%s, %s);", outputVar, fn, a.InputVariable, a.Expression) } // Attribute-based aggregate: SUM($list.Attr) if attrName != "" && a.Function != microflows.AggregateFunctionCount { - return fmt.Sprintf("$%s = %s($%s.%s);", outputVar, strings.ToLower(fn), a.InputVariable, attrName) + return fmt.Sprintf("$%s = %s($%s.%s);", outputVar, fn, a.InputVariable, attrName) } - return fmt.Sprintf("$%s = %s($%s);", outputVar, strings.ToLower(fn), a.InputVariable) + return fmt.Sprintf("$%s = %s($%s);", outputVar, fn, a.InputVariable) case *microflows.RetrieveAction: outputVar := a.OutputVariable @@ -2034,3 +2049,37 @@ func queueClauseMDL(qs *microflows.QueueSettings) string { } return " in queue " + qs.Queue } + +// mdlAggregateKeyword maps a Mendix aggregate function to the MDL keyword that +// parses back to it, reporting false for one this build cannot express. +// +// DESCRIBE used to render an aggregate by lowercasing whatever was stored, +// which silently assumed every value of Mendix's enumeration was also an MDL +// keyword. It was not: Reduce, All and Any described as MDL that the grammar +// then rejected (#1004). Mapping explicitly means a function added to Mendix +// later fails TestAggregateKeywordsCoverEveryFunction rather than a user's +// script. +func mdlAggregateKeyword(fn microflows.AggregateFunction) (string, bool) { + switch fn { + case microflows.AggregateFunctionCount, "": + // Count is also the fallback for an activity with no function stored, + // which is what Mendix defaults a fresh aggregate to. + return "count", true + case microflows.AggregateFunctionSum: + return "sum", true + case microflows.AggregateFunctionAverage: + return "average", true + case microflows.AggregateFunctionMin: + return "minimum", true + case microflows.AggregateFunctionMax: + return "maximum", true + case microflows.AggregateFunctionReduce: + return "reduce", true + case microflows.AggregateFunctionAll: + return "all", true + case microflows.AggregateFunctionAny: + return "any", true + default: + return "", false + } +} From 305fd926e2bb24a8018fbc7d291deddae662a1ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 19:16:53 +0000 Subject: [PATCH 13/35] docs(microflows): the eight aggregate functions, and an #1004 repro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates were undocumented in the quick reference and the write-microflows skill, and the syntax topic listed three of the eight. All three now cover the full set, with reduce's mandatory initial/returns and the reason they are mandatory. The bug test records what the reference documents showed that Mendix's own guide does not — that a return type is stored for All and Any too — and is re-runnable, so a second exec against an in-sync project must report "Unchanged microflow". Refs #1004 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG --- .claude/skills/fix-issue.md | 1 + .../reference/data-operations.md | 37 ++++++++ cmd/mxcli/syntax/features_microflow.go | 5 +- docs/01-project/MDL_QUICK_REFERENCE.md | 3 + .../1004-aggregate-reduce-all-any.mdl | 93 +++++++++++++++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 mdl-examples/bug-tests/1004-aggregate-reduce-all-any.mdl diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 264391133..b8a619300 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -762,3 +762,4 @@ extracting `OffsetExpression`/`LimitExpression`. | Every page menu item in an **mxcli-authored navigation profile** loses the page's own title, and `mx check` reports one **CW0263 "Empty template"** warning per item while errors stay at 0 (16 of them on a real 11.12.3 app) | `Forms$FormSettings.TitleOverride` written as an **empty** `Microflows$TextTemplate` instead of `null` — the #812 defect above, in the three writers #812 did not touch. An empty template is an override to `""`, not the absence of one. Unlike the ShowPage/button paths there is nothing to preserve: `types.NavMenuItemSpec` has no title field, so MDL cannot author a navigation title override and the value is unconditionally null | `sdk/mpr/writer_navigation.go` (`buildFormSettingsBson`), `mdl/backend/modelsdk/navigation_write.go` (`navFormSettingsBson`), `modelsdk/mpr/nav_patch.go` (`navpBuildFormSettingsBson`) | Emit `{Key: "TitleOverride", Value: nil}` in all three. **Grep the builder's callers before concluding a navigation fix is menu-only** — each of the three is also the profile's **login page** builder (`writer_navigation.go:116`, `navigation_write.go:127`, `nav_patch.go:122`, plus `navigation_profile_add.go:108`), so one edit per engine covers both `Forms$FormAction` and `LoginPageSettings`, and a fix aimed only at menu items would have missed half the emitters. Verify against a **Studio Pro document already in the project**, not against the warning count: a blank app's own navigation unit stores `TitleOverride = null` on both the login settings and the home menu item. Read it with `f=$(grep -ral NavigationDocument app/mprcontents \| head -1)` — `grep -a` is required, a `.mxunit` is raw BSON and plain `grep -rl` skips it as binary — then `strings -a "$f" \| grep -c TextTemplate`: **4 before (3 page items + login page), 0 after**, with `grep -c TitleOverride` staying at 4 so the key is still written. Measured on 11.12.0 on both engines. These three paths are raw `bson.D` → `bson.Marshal` → `UpdateRawUnit`, so #812's second trap (a `codec.RegisterTypeDefaults` `NullFields` entry clobbered by another registration for the same `$Type`) cannot apply — which also means a shape assertion on the builder is the only unit-level guard there is. Repro `mdl-examples/bug-tests/989-navigation-title-override.mdl`. PR #989 | | `DESCRIBE NAVIGATION` prints `home page` and the menu but **silently omits `login page` and `not found page`**, so pasting its output back (the documented copy workflow) deletes both from the profile. The clauses are on disk and `MXCLI_ENGINE=legacy` prints them | The **reader**, not the writer: `mdl/backend/modelsdk/navigation_read.go` type-asserted only the `$Type`s `modelsdk/gen` declares for those two slots, and neither is what the documents carry — `LoginPageSettings` is stored as `Forms$FormSettings` with the page under `Form` (gen expects `Navigation$NavigationProfileLoginFormSettings` / `LoginPage`), and `NotFoundHomepage` as `Navigation$HomePage` (gen and `generated/metamodel` both expect `Navigation$NotFoundHomePage`). A failed type assertion leaves the field empty, so the loss is silent | `mdl/backend/modelsdk/navigation_read.go` (`navLoginPageOf`, `navNotFoundPageOf`), cross-check `generated/metamodel/types.go` `NavigationNavigationProfile` | **The other engine is the control.** Legacy read the same bytes correctly throughout, which is what identifies a reader bug: `describe navigation X` on both engines must agree, and a disagreement localises the defect to the one that reads through gen. Accept the `$Type` the documents actually carry and keep gen's as a fallback branch. Note the two slots fail in **opposite directions** and want opposite fixes: for the login page a real Studio Pro document and `generated/metamodel` agree with the writers, so **gen** is wrong; for the not-found page — Studio Pro's **"Fallback page"** — metamodel and gen agree with each other and the three mxcli **writers** are the odd one out, emitting `Navigation$HomePage` where Studio Pro stores `Navigation$NotFoundHomePage`. Only a reference document could tell those apart, since mxbuild accepts either; ako/TestApp supplied it. Keep reading both `$Type`s regardless: documents written before the writer fix carry the `HomePage` spelling and must keep round-tripping. Repro `mdl-examples/bug-tests/navigation-describe-profile-pages.mdl` | | A document mxcli writes carries a different **typed-array marker** (the leading `int32` of a Mendix array) than the equivalent Studio Pro document — e.g. every list in a `CREATE OR REPLACE NAVIGATION` profile was `1` where Studio Pro writes `2` or `3`. No error, no warning, no build failure: it renders and opens | The writers hand-build `bson.A{int32(1)}` per list. The marker is a **per-field constant**, not a function of the list's contents (`Forms$FormSettings.ParameterMappings` is `2` in 816 empty and 306 non-empty documents alike), so it cannot be derived — it has to be read off real documents | `sdk/mpr/writer_navigation.go` + `mdl/backend/modelsdk/navigation_write.go` + `modelsdk/mpr/nav_patch.go` (`navMarker*` / `navpMarker*` constants), `mdl/backend/modelsdk/navigation_profile_add.go`, `modelsdk/codec/defaults.go` (`RegisterListMarker`) for the codec paths | **Census, don't reason.** Walk every `.mxunit` on the machine, tabulate `(parent $Type, field, marker, empty?)`, and take the value the Studio Pro documents carry — 19,078 files across 54 projects settled five of six navigation fields outright. **`int32(1)` is NOT invalid**, whatever `debug-bson.md` used to say: a Marketplace `.mpk` mxcli has never touched uses it for `CustomWidgets$WidgetValueType.AllowedTypes` (212k occurrences) and `Forms$Page.AllowedModuleRoles`. Believing otherwise turns a per-field mismatch into a phantom corruption bug and sends the fix in the wrong direction. Where the census has no observation, **find a document that has one** rather than picking: `HomeItems` was `2` in all 51 stored profiles but every one was empty, and `navigation_profile_add.go` wrote `3` from a PED session that could not be re-run. ako/TestApp settled it — a Studio Pro-authored profile whose `HomeItems` holds two `Navigation$RoleBasedHomePage` elements at marker **2**, the non-empty case the census could not reach. One project with the feature actually configured beats any amount of reasoning about empty lists. Verify by dumping the written document and the project's own pristine reference and diffing the marker column, not by `mx check`, which is silent on all of it | +| `DESCRIBE MICROFLOW` emits `reduce($list, expr)` (or `all(...)` / `any(...)`) and mxcli's own checker then rejects its own output: "set 'X' calls 'reduce()', which is not a Mendix expression function [MDL044]". Note the word **set** — the parser did not reject the call, it read the line as a Change Variable whose value happened to be a function call, and MDL044 was right about the rest | DESCRIBE rendered an aggregate as `strings.ToLower(storedEnumValue)`, assuming every value of Mendix's `AggregateFunction` was also an MDL keyword. Mendix has eight, the grammar had five. Underneath sat a quieter defect: Mendix stores a Reduce's seed and result type in `ReduceInitialValueExpression` / `ReduceReturnDataType` and the semantic model had no field for either, so a grammar-only fix would have round-tripped the syntax while deleting the fold | `mdl/grammar/MDLLexer.g4` (REDUCE/ANY/INITIAL + the `keyword` rule so they stay usable as identifiers), `mdl/grammar/domains/MDLMicroflow.g4` (`listAggregateOperation` + `reduceFoldOptions`), `mdl/ast/ast_microflow.go`, `mdl/visitor/visitor_microflow_actions.go`, `mdl/executor/cmd_microflows_builder_actions.go`, `mdl/executor/cmd_microflows_format_action.go` (`mdlAggregateKeyword`), plus all four read/write paths: `sdk/mpr/parser_microflow.go`, `sdk/mpr/writer_microflow_actions.go`, `mdl/backend/modelsdk/microflow_read_actions.go`, `mdl/backend/modelsdk/microflow_write.go` | **A renderer that stringifies an enum outgrows its grammar silently** — the sibling `formatListOperation` switches on concrete types and cannot, which is the shape to prefer. The guard is a describe→parse loop over `microflows.AllAggregateFunctions` (`TestDescribedAggregateParsesBack`), so a ninth Mendix function fails a test rather than a user's script. **Get a reference document before believing the vendor docs**: Mendix's reference guide says a return type is "not applicable" to All/Any, but Studio Pro writes `ReduceReturnDataType` as Boolean on both, and `Attribute` as `""` when unused — all three activities now re-serialize byte-identically to Studio Pro's. `mx check` is no help here (0 errors before and after); the controls are the origin/main parse (`reduce`/`all`/`any` → Change Variable, with `sum` → aggregate as the positive control) and reverting the write path (`TestReduceFoldReachesStorage` then reports the two keys missing). #1004 | diff --git a/.claude/skills/mendix/write-microflows/reference/data-operations.md b/.claude/skills/mendix/write-microflows/reference/data-operations.md index 3c4ca3fd6..3d50f5342 100644 --- a/.claude/skills/mendix/write-microflows/reference/data-operations.md +++ b/.claude/skills/mendix/write-microflows/reference/data-operations.md @@ -159,6 +159,43 @@ set $Found = contains($Items, $Item); ``` The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`. + +### Aggregates — all eight, including `reduce`, `all` and `any` + +An Aggregate list activity folds a list into one value. `count` takes only the +list; the rest take either an **attribute** or an **expression** over +`$currentObject`. + +```mdl +$Count = count($Orders); +$Total = sum($Orders.Amount); -- attribute form +$Total = sum($Orders, $currentObject/Amount * 1.21); -- expression form +$Avg = average($Orders.Amount); +$Min = minimum($Orders.Amount); +$Max = maximum($Orders.Amount); + +-- Boolean predicates over every item. No seed, always Boolean. +$AllPaid = all($Orders, $currentObject/Paid); +$AnyLate = any($Orders, $currentObject/DueDate < [%CurrentDateTime%]); + +-- REDUCE folds with a running total. $currentResult is the accumulator. +$Discounted = reduce( + $Orders, + $currentResult + $currentObject/Amount * 0.9, + initial: 0, + returns: Decimal +); +``` + +**`reduce` needs `initial:` and `returns:` and neither can be inferred.** Mendix +stores both beside the expression, and the fold is meaningless without a seed +and a result type — so MDL makes them mandatory rather than guessing. `all` and +`any` take neither: they never accumulate, and always fold to Boolean. + +Do not reach for `reduce` where `sum` will do. It exists for folds Mendix has no +dedicated function for — running a string together, or carrying a value forward +that depends on the previous item. + ## Database Operations ### RETRIEVE Statement diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 9aa9b9a45..173792420 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -168,9 +168,10 @@ func init() { // RANGE was authorable but absent from this topic, so the paging // form could not be discovered from the CLI at all (issue #966). "range", "paging", "pagination", "offset", "limit", "amount", "page", + "reduce", "all", "any", "fold", }, - Syntax: "$List = CREATE LIST OF Module.Entity;\nADD $Item TO $List;\nREMOVE $Item FROM $List;\n$Result = HEAD($List);\n$Result = TAIL($List);\n$Result = FIND($List, condition);\n$Result = FILTER($List, condition);\n$Result = SORT($List, attr ASC);\n$Result = UNION($L1, $L2);\n$Result = INTERSECT($L1, $L2);\n$Result = SUBTRACT($L1, $L2);\n$Result = RANGE($List, offset, amount);\n$Result = RANGE($List, offset);\n$Count = COUNT($List);\n$Sum = SUM($List.Attr);\n$Avg = AVERAGE($List.Attr);\n\n-- RANGE takes OFFSET first, then AMOUNT, and needs at least ONE of them:\n-- RANGE($L, $Offset, $Amount) page: skip $Offset, take $Amount\n-- RANGE($L, 0, $Amount) first $Amount\n-- RANGE($L, $Offset) skip $Offset, take the rest\n-- RANGE($L) with no bound is CE6520 at build time (mxcli check: MDL068).", - Example: "$AllOrders = CREATE LIST OF MyModule.Order;\nADD $NewOrder TO $AllOrders;\n$First = HEAD($AllOrders);\n$Pending = FILTER($AllOrders, Status = 'Pending');\n$Sorted = SORT($Pending, CreateDate DESC);\n$Page = RANGE($Sorted, $Offset, $PageSize);\n$Total = SUM($AllOrders.Amount);", + Syntax: "$List = CREATE LIST OF Module.Entity;\nADD $Item TO $List;\nREMOVE $Item FROM $List;\n$Result = HEAD($List);\n$Result = TAIL($List);\n$Result = FIND($List, condition);\n$Result = FILTER($List, condition);\n$Result = SORT($List, attr ASC);\n$Result = UNION($L1, $L2);\n$Result = INTERSECT($L1, $L2);\n$Result = SUBTRACT($L1, $L2);\n$Result = RANGE($List, offset, amount);\n$Result = RANGE($List, offset);\n\n-- Aggregates. Mendix has eight; each takes an attribute or an expression\n-- over $currentObject.\n$Count = COUNT($List);\n$Sum = SUM($List.Attr);\n$Sum = SUM($List, expression);\n$Avg = AVERAGE($List.Attr);\n$Min = MINIMUM($List.Attr);\n$Max = MAXIMUM($List.Attr);\n$AllMatch = ALL($List, boolean-expression);\n$AnyMatch = ANY($List, boolean-expression);\n\n-- REDUCE folds the list into one value. $currentResult is the running\n-- total; both INITIAL and RETURNS are required and cannot be inferred.\n$Folded = REDUCE($List, expression, initial: value, returns: Type);\n\n-- RANGE takes OFFSET first, then AMOUNT, and needs at least ONE of them:\n-- RANGE($L, $Offset, $Amount) page: skip $Offset, take $Amount\n-- RANGE($L, 0, $Amount) first $Amount\n-- RANGE($L, $Offset) skip $Offset, take the rest\n-- RANGE($L) with no bound is CE6520 at build time (mxcli check: MDL068).", + Example: "$AllOrders = CREATE LIST OF MyModule.Order;\nADD $NewOrder TO $AllOrders;\n$First = HEAD($AllOrders);\n$Pending = FILTER($AllOrders, Status = 'Pending');\n$Sorted = SORT($Pending, CreateDate DESC);\n$Page = RANGE($Sorted, $Offset, $PageSize);\n$Total = SUM($AllOrders.Amount);\n$AllPaid = ALL($AllOrders, $currentObject/Paid);\n$AnyLate = ANY($AllOrders, $currentObject/DueDate < [%CurrentDateTime%]);\n$Discounted = REDUCE(\n $AllOrders,\n $currentResult + $currentObject/Amount * 0.9,\n initial: 0,\n returns: Decimal\n);", SeeAlso: []string{"microflow.retrieve"}, }) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index ce9b8a2b4..ffed369de 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -509,6 +509,9 @@ it is for pages. | Retrieve (DB) | `retrieve $Var from Module.Entity [where condition];` | Database XPath retrieve | | Retrieve (Assoc) | `retrieve $list from $Parent/Module.AssocName;` | Retrieve by association | | Add to list | `add expression to $list;` | Also accepts existing `add $item to $list;` form | +| Aggregate a list | `$Total = sum($list.Attr);` / `$Total = sum($list, expression);` | `count` (list only), `sum`, `average`, `minimum`, `maximum` — attribute or expression over `$currentObject` | +| All / any | `$AllMatch = all($list, boolean-expression);` | And `any(...)`. No seed, always Boolean — Mendix stores a Boolean return type for both | +| Reduce a list | `$Folded = reduce($list, expression, initial: value, returns: Type);` | `$currentResult` is the accumulator. `initial` and `returns` are **required** — Mendix stores both and neither is inferable, so MDL will not guess (#1004) | | Call microflow | `$Result = call microflow Module.Name (Param = $value);` | A Mendix **expression** cannot call anything — `declare $r Boolean = Module.Name(...)` is CE0117 (MDL066) | | Call a rule | `if Module.SomeRule (Param = $value) then ... end if;` | A decision is the **only** place a rule can be evaluated; there is no call activity for one. The name must resolve to a rule — a microflow there is CE0117 | | Call microflow on a queue | `call microflow Module.Name (Param = $value) in queue Module.Queue;` | Background execution; the queue must exist (CE1613) | diff --git a/mdl-examples/bug-tests/1004-aggregate-reduce-all-any.mdl b/mdl-examples/bug-tests/1004-aggregate-reduce-all-any.mdl new file mode 100644 index 000000000..40101cdfd --- /dev/null +++ b/mdl-examples/bug-tests/1004-aggregate-reduce-all-any.mdl @@ -0,0 +1,93 @@ +-- ============================================================================ +-- Bug #1004: DESCRIBE emitted `reduce(...)`, which mxcli's own checker rejected +-- ============================================================================ +-- +-- Symptom (before fix): +-- Describing a microflow containing Mendix's Reduce aggregate produced +-- +-- $Reduce = reduce($ContractList, $currentResult + $currentObject/Number); +-- +-- and running that same line back through mxcli failed: +-- +-- ✗ set 'Reduce' calls 'reduce()', which is not a Mendix expression +-- function — the build fails CE0117 "Error(s) in expression" [MDL044] +-- +-- Note "set": the parser did not reject `reduce(...)`, it read the line as a +-- plain Change Variable whose value happened to be a function call. MDL044 +-- then correctly observed that Mendix has no `reduce()` expression function. +-- +-- Root cause: +-- DESCRIBE rendered an aggregate by lowercasing whatever Mendix had stored, +-- which assumed every value of the AggregateFunction enumeration was also an +-- MDL keyword. Mendix has eight; the grammar had five. Reduce, All and Any +-- all described as MDL the parser could not read back. +-- +-- Underneath that was a quieter defect the grammar alone would not have +-- fixed: Mendix stores a Reduce's seed and result type in +-- ReduceInitialValueExpression and ReduceReturnDataType, and the semantic +-- model had no field for either — so mxcli read them as nothing and would +-- have written them back as nothing, deleting the fold from a microflow that +-- still passed `mx check`. +-- +-- Measured (ako/TestApp, Mendix 11.14, Microflows.MicroflowReduce): +-- Studio Pro writes BOTH properties on every AggregateAction, not only on +-- Reduce — its All and Any activities each carry an empty initial value and a +-- Boolean return type, even though Mendix's own reference guide says a return +-- type is "not applicable" to them. Attribute is likewise stored as "" when +-- unused. After the fix all three activities re-serialize byte-identically to +-- what Studio Pro wrote. +-- +-- After fix: +-- reduce/all/any are aggregate statements. REDUCE names its seed and result +-- type because Mendix requires both and neither can be inferred; ALL and ANY +-- take neither, since they never accumulate and always fold to Boolean. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1004-aggregate-reduce-all-any.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest1004.MF_Aggregates" +-- +-- The describe output must be a fixpoint under describe → exec → describe. +-- The script is re-runnable, so a second `exec` against an in-sync project +-- must report "Unchanged microflow" (ADR-0008) rather than rewriting it. +-- ============================================================================ + +create module BugTest1004; + +create or modify entity BugTest1004.Order ( + Amount : decimal, + Paid : boolean +); +/ + +create or modify microflow BugTest1004.MF_Aggregates ( + $Orders: list of BugTest1004.Order +) +begin + -- The five functions that always round-tripped, as the control: if these + -- break, the change broke aggregates generally rather than the new three. + $Count = count($Orders); + $Total = sum($Orders.Amount); + $Avg = average($Orders.Amount); + $Min = minimum($Orders.Amount); + $Max = maximum($Orders.Amount); + + -- Boolean predicates over every item. No seed, always Boolean. + $AllPaid = all($Orders, $currentObject/Paid); + $AnyUnpaid = any($Orders, not($currentObject/Paid)); + + -- The fold. $currentResult is the accumulator; both extra inputs are + -- required, and dropping either is the silent half of this bug. + $Discounted = reduce( + $Orders, + $currentResult + $currentObject/Amount * 0.9, + initial: 0, + returns: Decimal + ); + + -- An expression-form aggregate, to pin that the older functions still take + -- one and did not get folded into the reduce path. + $WithVat = sum($Orders, $currentObject/Amount * 1.21); + + return; +end; +/ From 862fb69b3cdbdaa11f6bc64e6bf9728cb0b79bbe Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 31 Aug 2026 21:09:27 +0000 Subject: [PATCH 14/35] fix(test): make the no-annotation control a valid workflow main is red. TestDescribeWorkflow_NoAnnotationEmitsNoComment fails with unexpected violations [MDL-WF05] for a plain jump and both PRs that produced it were green. #351 added the test with a fixture that is a lone JumpToActivity whose target does not exist, and asserted "no violations at all". #350 then added MDL-WF05, which reports exactly that dangling target. Neither CI run could see the other's change. The rule is right; the fixture was not. It now carries the jump's target as a real activity, so the workflow is valid and the assertion means what it says rather than "no rule has been written yet that notices this". Co-Authored-By: Claude Opus 5 --- .claude/skills/fix-issue.md | 1 + mdl/executor/issue1007_annotation_emit_test.go | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index c9ebc5806..4462d0516 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -770,3 +770,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `DESCRIBE NAVIGATION` prints `home page` and the menu but **silently omits `login page` and `not found page`**, so pasting its output back (the documented copy workflow) deletes both from the profile. The clauses are on disk and `MXCLI_ENGINE=legacy` prints them | The **reader**, not the writer: `mdl/backend/modelsdk/navigation_read.go` type-asserted only the `$Type`s `modelsdk/gen` declares for those two slots, and neither is what the documents carry — `LoginPageSettings` is stored as `Forms$FormSettings` with the page under `Form` (gen expects `Navigation$NavigationProfileLoginFormSettings` / `LoginPage`), and `NotFoundHomepage` as `Navigation$HomePage` (gen and `generated/metamodel` both expect `Navigation$NotFoundHomePage`). A failed type assertion leaves the field empty, so the loss is silent | `mdl/backend/modelsdk/navigation_read.go` (`navLoginPageOf`, `navNotFoundPageOf`), cross-check `generated/metamodel/types.go` `NavigationNavigationProfile` | **The other engine is the control.** Legacy read the same bytes correctly throughout, which is what identifies a reader bug: `describe navigation X` on both engines must agree, and a disagreement localises the defect to the one that reads through gen. Accept the `$Type` the documents actually carry and keep gen's as a fallback branch. Note the two slots fail in **opposite directions** and want opposite fixes: for the login page a real Studio Pro document and `generated/metamodel` agree with the writers, so **gen** is wrong; for the not-found page — Studio Pro's **"Fallback page"** — metamodel and gen agree with each other and the three mxcli **writers** are the odd one out, emitting `Navigation$HomePage` where Studio Pro stores `Navigation$NotFoundHomePage`. Only a reference document could tell those apart, since mxbuild accepts either; ako/TestApp supplied it. Keep reading both `$Type`s regardless: documents written before the writer fix carry the `HomePage` spelling and must keep round-tripping. Repro `mdl-examples/bug-tests/navigation-describe-profile-pages.mdl` | | A document mxcli writes carries a different **typed-array marker** (the leading `int32` of a Mendix array) than the equivalent Studio Pro document — e.g. every list in a `CREATE OR REPLACE NAVIGATION` profile was `1` where Studio Pro writes `2` or `3`. No error, no warning, no build failure: it renders and opens | The writers hand-build `bson.A{int32(1)}` per list. The marker is a **per-field constant**, not a function of the list's contents (`Forms$FormSettings.ParameterMappings` is `2` in 816 empty and 306 non-empty documents alike), so it cannot be derived — it has to be read off real documents | `sdk/mpr/writer_navigation.go` + `mdl/backend/modelsdk/navigation_write.go` + `modelsdk/mpr/nav_patch.go` (`navMarker*` / `navpMarker*` constants), `mdl/backend/modelsdk/navigation_profile_add.go`, `modelsdk/codec/defaults.go` (`RegisterListMarker`) for the codec paths | **Census, don't reason.** Walk every `.mxunit` on the machine, tabulate `(parent $Type, field, marker, empty?)`, and take the value the Studio Pro documents carry — 19,078 files across 54 projects settled five of six navigation fields outright. **`int32(1)` is NOT invalid**, whatever `debug-bson.md` used to say: a Marketplace `.mpk` mxcli has never touched uses it for `CustomWidgets$WidgetValueType.AllowedTypes` (212k occurrences) and `Forms$Page.AllowedModuleRoles`. Believing otherwise turns a per-field mismatch into a phantom corruption bug and sends the fix in the wrong direction. Where the census has no observation, **find a document that has one** rather than picking: `HomeItems` was `2` in all 51 stored profiles but every one was empty, and `navigation_profile_add.go` wrote `3` from a PED session that could not be re-run. ako/TestApp settled it — a Studio Pro-authored profile whose `HomeItems` holds two `Navigation$RoleBasedHomePage` elements at marker **2**, the non-empty case the census could not reach. One project with the feature actually configured beats any amount of reasoning about empty lists. Verify by dumping the written document and the project's own pristine reference and diffing the marker column, not by `mx check`, which is silent on all of it | | `DESCRIBE MICROFLOW` emits `reduce($list, expr)` (or `all(...)` / `any(...)`) and mxcli's own checker then rejects its own output: "set 'X' calls 'reduce()', which is not a Mendix expression function [MDL044]". Note the word **set** — the parser did not reject the call, it read the line as a Change Variable whose value happened to be a function call, and MDL044 was right about the rest | DESCRIBE rendered an aggregate as `strings.ToLower(storedEnumValue)`, assuming every value of Mendix's `AggregateFunction` was also an MDL keyword. Mendix has eight, the grammar had five. Underneath sat a quieter defect: Mendix stores a Reduce's seed and result type in `ReduceInitialValueExpression` / `ReduceReturnDataType` and the semantic model had no field for either, so a grammar-only fix would have round-tripped the syntax while deleting the fold | `mdl/grammar/MDLLexer.g4` (REDUCE/ANY/INITIAL + the `keyword` rule so they stay usable as identifiers), `mdl/grammar/domains/MDLMicroflow.g4` (`listAggregateOperation` + `reduceFoldOptions`), `mdl/ast/ast_microflow.go`, `mdl/visitor/visitor_microflow_actions.go`, `mdl/executor/cmd_microflows_builder_actions.go`, `mdl/executor/cmd_microflows_format_action.go` (`mdlAggregateKeyword`), plus all four read/write paths: `sdk/mpr/parser_microflow.go`, `sdk/mpr/writer_microflow_actions.go`, `mdl/backend/modelsdk/microflow_read_actions.go`, `mdl/backend/modelsdk/microflow_write.go` | **A renderer that stringifies an enum outgrows its grammar silently** — the sibling `formatListOperation` switches on concrete types and cannot, which is the shape to prefer. The guard is a describe→parse loop over `microflows.AllAggregateFunctions` (`TestDescribedAggregateParsesBack`), so a ninth Mendix function fails a test rather than a user's script. **Get a reference document before believing the vendor docs**: Mendix's reference guide says a return type is "not applicable" to All/Any, but Studio Pro writes `ReduceReturnDataType` as Boolean on both, and `Attribute` as `""` when unused — all three activities now re-serialize byte-identically to Studio Pro's. `mx check` is no help here (0 errors before and after); the controls are the origin/main parse (`reduce`/`all`/`any` → Change Variable, with `sum` → aggregate as the positive control) and reverting the write path (`TestReduceFoldReachesStorage` then reports the two keys missing). #1004 | +| `main` goes red on a test that passed in **both** PRs that touched it — here `TestDescribeWorkflow_NoAnnotationEmitsNoComment`: `unexpected violations [MDL-WF05] for a plain jump` | Two PRs merged in sequence. One added a validator rule (MDL-WF05, dangling `jump to` target); the other's control test asserted `len(violations) == 0` over a fixture that was **not a valid workflow** — a lone jump whose target did not exist. Each CI run was green because neither saw the other's change | `mdl/executor/issue1007_annotation_emit_test.go` (the fixture), `mdl/executor/validate_workflow_jump.go` (the rule, which is right) | **A test fixture that is not a valid instance of the thing under test is a landmine for the next rule.** "No violations at all" is only meaningful over input that *should* have none; over an invalid fixture it silently asserts "no rule has been written yet that notices this". Fix the **fixture**, not the rule. Generalise: when a test asserts the ABSENCE of diagnostics, make the input something you would be happy to ship. Two green PRs can still merge to red and **neither PR's CI can detect it** — the only protection is a fixture that does not depend on which rules exist today. Found by running `make test` on an unrelated docs branch cut from the merged main, which is an argument for doing that on any branch cut after a batch merge. ako/mxcli#350, ako/mxcli#351 | diff --git a/mdl/executor/issue1007_annotation_emit_test.go b/mdl/executor/issue1007_annotation_emit_test.go index 3ee58aea7..b01c8885c 100644 --- a/mdl/executor/issue1007_annotation_emit_test.go +++ b/mdl/executor/issue1007_annotation_emit_test.go @@ -123,16 +123,27 @@ func TestDescribeWorkflow_MultiLineAnnotationCommentsEveryLine(t *testing.T) { // The control for the whole change: an activity with no annotation must emit // exactly what it did before, with no stray comment line. +// +// The fixture carries the jump's target as a real activity. It did not, and the +// test asserted "no violations at all" — which held until MDL-WF05 landed and +// correctly reported the dangling target. Both PRs were green alone and red +// together, because neither CI run saw the other's change; a fixture that is a +// VALID workflow is what makes the assertion mean what it says. func TestDescribeWorkflow_NoAnnotationEmitsNoComment(t *testing.T) { + target := &workflows.UserTask{} + target.Name = "Review" + target.Caption = "Review" + target.Page = "M.ReviewPage" + jump := &workflows.JumpToActivity{TargetActivity: "Review"} jump.Name = "j1" - src, parseErrs, rules := describeAndValidate(t, jump) + src, parseErrs, rules := describeAndValidate(t, target, jump) if parseErrs != nil { t.Fatalf("parse: %v\n%s", parseErrs, src) } if len(rules) > 0 { - t.Errorf("unexpected violations %v for a plain jump:\n%s", rules, src) + t.Errorf("unexpected violations %v for a valid workflow with no annotation:\n%s", rules, src) } if strings.Contains(src, "annotation") { t.Errorf("emitted an annotation for an activity that has none:\n%s", src) From ee76fa9d80a3eccbe666c07600c7c6725d3a1476 Mon Sep 17 00:00:00 2001 From: Ako Date: Mon, 31 Aug 2026 21:10:21 +0000 Subject: [PATCH 15/35] refactor(skills): move the bug findings out of fix-issue.md into JSONL shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-issue.md had reached 1.05 MB across 630 findings. That is past a context window, past what GitHub's web editor will open, and past what the wiki's own /mxcli-dev:wiki-sync can consume — its Phase 2 requires reading a source in full, so the step that digests findings into docs-wiki/bug-patterns/ pages has been blocked since the day those three pages were written. The skill instructed everyone to read a file that could not be read. It had also stopped being a table. Only 227 of the 630 rows were still under the header; the other 403 had accreted in twelve separate runs through the document, most rendering as literal text rather than table rows. The findings are now one JSON object per line under .claude/skills/fix-issue/findings/, sharded by area into nine files (largest 423 KB). fix-issue.md keeps the procedure and drops to 37 KB. Nine shards, not one file per finding: 630 files would trade a too-large file for a directory nobody can scan, and the conflicts that motivated sharding only occur between fixes touching the same area. Records carry area / symptom / cause / file / insight, plus refs, ce codes and MDL rule ids extracted from the text. 68 of the 630 rows could not be split into four columns (an unescaped pipe, or a row that never had four cells); those keep a verbatim `raw` field, and the format documents that a record has either the four fields or raw. The extraction is verified lossless: every record regenerates its original row, and the multiset of 630 is byte-identical to what was committed. merge=union moves from fix-issue.md to findings/*.jsonl. That is the right driver on the right unit — union applies file-wide, so on the old mixed prose-and-table file two branches editing the same prose line would have silently kept both. The note that shipped with it predicted this exact fix. It does not stop PR conflicts either way, since GitHub's server-side merge does not run merge drivers; sharding by area is what reduces them. make check-findings (scripts/check-findings.sh, wired into CI) validates every line: DuckDB rejects a whole file on one bad line, so a typo in an appended finding would take out every query over that area. Pointers updated in CLAUDE.md, CONTRIBUTING.md, docs-wiki/README.md, maintain-wiki.md and the four wiki pages that cite the table as a source. SYNC_LOG.md is append-only history and is left alone. Co-Authored-By: Claude Opus 5 --- .claude/skills/fix-issue.md | 764 ++---------------- .claude/skills/fix-issue/findings/README.md | 52 ++ .../skills/fix-issue/findings/cmd-mxcli.jsonl | 95 +++ .../fix-issue/findings/mdl-backend.jsonl | 83 ++ .../fix-issue/findings/mdl-executor.jsonl | 247 ++++++ .../fix-issue/findings/mdl-grammar.jsonl | 53 ++ .../skills/fix-issue/findings/mdl-other.jsonl | 52 ++ .../fix-issue/findings/mdl-visitor.jsonl | 28 + .../skills/fix-issue/findings/modelsdk.jsonl | 16 + .claude/skills/fix-issue/findings/other.jsonl | 16 + .claude/skills/fix-issue/findings/sdk.jsonl | 40 + .claude/skills/maintain-wiki.md | 10 +- .gitattributes | 22 +- CLAUDE.md | 8 +- CONTRIBUTING.md | 2 +- Makefile | 8 +- docs-wiki/README.md | 11 +- docs-wiki/bug-patterns/bson-numeric-width.md | 6 +- docs-wiki/bug-patterns/visitor-wiring-gaps.md | 6 +- .../bug-patterns/widget-type-object-drift.md | 6 +- docs-wiki/models/element-identity.md | 2 +- scripts/check-findings.sh | 53 ++ 22 files changed, 860 insertions(+), 720 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/README.md create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli.jsonl create mode 100644 .claude/skills/fix-issue/findings/mdl-backend.jsonl create mode 100644 .claude/skills/fix-issue/findings/mdl-executor.jsonl create mode 100644 .claude/skills/fix-issue/findings/mdl-grammar.jsonl create mode 100644 .claude/skills/fix-issue/findings/mdl-other.jsonl create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor.jsonl create mode 100644 .claude/skills/fix-issue/findings/modelsdk.jsonl create mode 100644 .claude/skills/fix-issue/findings/other.jsonl create mode 100644 .claude/skills/fix-issue/findings/sdk.jsonl create mode 100755 scripts/check-findings.sh diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index c9ebc5806..412aa1316 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -2,22 +2,20 @@ A fast-path workflow for diagnosing and fixing bugs in mxcli. -Each fix appends one row to the symptom table below. That table is **evidence, not -reading material** — it is ~1 MB and cannot be loaded whole. The entry point for a -diagnosis is [`docs-wiki/bug-patterns/`](../../docs-wiki/bug-patterns/), which digests -these rows into failure classes; the table is where you drill down for the specific -instance. +Each fix appends one finding to `findings/*.jsonl`. Those are **evidence, not +reading material**: 630 findings at ~1.7 KB each. The entry point for a diagnosis +is [`docs-wiki/bug-patterns/`](../../docs-wiki/bug-patterns/), which digests them +into failure classes; the findings are where you drill down for the instance. ## How to Use 1. **Start with the failure class.** Skim - [`docs-wiki/bug-patterns/`](../../docs-wiki/bug-patterns/) — it is small, and it tells - you which layer the bug lives in. Coverage is partial (three pages against 600+ rows), - so a miss there means the finding has not been digested yet, **not** that it is new. -2. **Then find the instance**: `grep -i '' .claude/skills/fix-issue.md`. Go - straight to the file the matching row names and follow its fix pattern. Do not open - this file's table whole — it is ~1 MB, and it is looked up by matching a symptom, not - read in order. + [`docs-wiki/bug-patterns/`](../../docs-wiki/bug-patterns/) — it is small, and it + tells you which layer the bug lives in. Coverage is partial (three pages against + 630 findings), so a miss there means the finding has not been digested yet, + **not** that it is new. +2. **Then find the instance** — see "Finding a prior fix" below. Go straight to the + file the matching record names and follow its insight. 3. Write a failing test first, then implement. 4. **Verify at the layer the symptom lives in.** Parser → unit test. BSON we write → unit test on the encoded document. Files on disk after `mx` runs → integration test @@ -28,269 +26,79 @@ instance. 5. **Prove the test detects the bug**: revert the fix and confirm it fails with the reported symptom. A test that has only ever run against fixed code has not been shown to detect anything. -6. After the fix: **add a new row** to the table if the symptom is not already covered. - **Append it at the END of the table, never at the top.** - -> **Conflicts are handled by git, not by where you insert.** Every bug fix touches -> this one file, so two branches fixing unrelated bugs write to the same place and git -> raises a conflict. That cost five resolution rounds in one week when rows went in -> under the header — and moving them to the end did **not** fix it: both sides still -> append to the same line, so the collision moved with the convention. PRs #76, #77 -> and #78 each hit it again afterwards. -> -> The actual fix is in `.gitattributes`: -> -> ``` -> .claude/skills/fix-issue.md merge=union -> ``` -> -> git's built-in `union` driver keeps **both** sides of a conflicting hunk instead of -> raising a conflict. Two fixes that each append a row now merge with no intervention -> and both rows present — verified by merging a simulated later fix into an open -> branch. Where you insert no longer affects merging at all. +6. After the fix: **append a finding** — see "Recording a fix" below. + +> **Why the findings are not in this file.** They were, as one Markdown table, and +> it reached 1.05 MB / 630 rows — past what fits in a context window and past what +> GitHub's web editor will open. It had also stopped being a table: only 227 of the +> 630 rows were still under the header, the other 403 having accreted in twelve +> separate runs through the document, most rendering as literal text. > -> Append at the end anyway, for a human reason: it keeps a fix's diff readable and the -> table roughly chronological. The table is looked up by matching a symptom, not read -> top to bottom, so position carries no meaning. +> `.gitattributes` gave `merge=union` to the whole file, which kept two concurrent +> appends instead of conflicting. That was the right driver on the wrong unit: union +> applies file-wide, so two branches editing the same *prose* line would silently +> keep both. The note that shipped with it called this exact shot — "if that starts +> happening, split the table into its own file so `union` covers only append-only +> content" — and the union driver now sits on `findings/*.jsonl`, where every line is +> an independent record and keeping both sides is always correct. > -> **One caveat.** `union` applies to the whole file, so two branches editing the same -> *prose* line would silently keep both rather than conflict — a visible duplicated -> line, not corruption. If that starts happening, split the table into its own file so -> `union` covers only append-only content. +> It never solved the conflicts anyway: GitHub's server-side merge does not run merge +> drivers, so every PR still had to merge `main` locally first. Sharding by area is +> what actually reduces them — two fixes now collide only when they touch the same +> area. --- -## Symptom → Layer → File Table - -| Symptom | Root cause layer | First file to open | Fix pattern | -|---------|-----------------|-------------------|-------------| -| Every popup opened by an **mxcli-authored** button or Show Page action shows a **blank caption** — just the `×` — while Studio Pro / marketplace popups in the same project are fine. Also: `show page M.P with title = 'X'` is silently ignored | `Forms$FormSettings` / `Forms$PageSettings` `TitleOverride` was written as an **empty** `Microflows$TextTemplate` instead of `null`. An empty template is not the absence of an override — it overrides the title with the empty string. The same unconditional write also discarded `ShowPageAction.OverridePageTitle`, so an authored override never reached BSON (`grep -rn OverridePageTitle` matched only the struct field and its assignment) | `sdk/mpr/writer_microflow_actions.go` (`titleOverrideValue`), `sdk/mpr/writer_widgets_action.go`, `mdl/backend/modelsdk/microflow_write.go` (`showPageFormSettingsToGen`), `mdl/backend/modelsdk/widget_write.go` (`formSettingsToGen` + the `Forms$FormSettings`/`Forms$PageSettings` defaults) | Emit `null` when there is no override and a populated `TextTemplate` when there is. **Beware two traps.** (1) The old comments claimed TitleOverride "must be non-nil", reasoning by analogy from #295, which was about `Forms$PageVariable` — a different field; the repo's own `debug-bson.md` already documented `TitleOverride: nil` as correct. When a comment cites an issue as justification, check that the issue is about the same field. (2) `codec.RegisterTypeDefaults` **overwrites rather than merges**, so two registrations for one `$Type` resolve silently by init order — a `NullFields` entry added next to the microflow writer was clobbered by the one in `widget_write.go` and the null never appeared. Grep for duplicate registrations before debugging a default that "does not apply". Repro `mdl-examples/bug-tests/812-showpage-title-override.mdl`. Issue #812 | -| A generated loop box is drawn far wider than the activities inside it (e.g. 880px around 440px of content), leaving a large empty area — "the visualization is poor" | `measureStatements` sums each element's full width **and** adds `HorizontalSpacing` between them, but `HorizontalSpacing` is a centre-to-centre *pitch*: the builder centres each activity on `posX` and advances by exactly that. Counting it on top of each width over-measures a run of n simple activities by `(n-1)*ActivityWidth` | `mdl/executor/layout.go` (`measureStatementsSpan`), used by `addLoopStatement`/`addWhileStatement` in `cmd_microflows_builder_control.go` | For a run of only simple activities the true span is `(n-1)*HorizontalSpacing + ActivityWidth`. **Do not guess the advance for a compound element** (IF/split, nested loop) — its `posX` advance comes from merge geometry (`mergeX + MergeSize + HorizontalSpacing/2`), and guessing under-sizes the box so activities land *outside* it, which is worse than a box that is too wide; those runs fall back to the conservative measure. Verify with a containment check: every child of a LoopedActivity must lie within `[0, width] × [0, height]`. Issue #790 | -| A microflow writes and `mxcli` reports success, but Studio Pro cannot open the project: `System.Collections.Generic.KeyNotFoundException: The given key '' was not present in the dictionary`. Triggered by a `break`/`continue` inside an if/case within a loop | `microflowObjectToGen`'s `default:` branch returns nil for an unhandled object type, so the event object was dropped at serialization while the SequenceFlow pointing at it was written — a DestinationPointer to a GUID that exists nowhere. Exactly the shape already fixed once for `Microflows$ErrorEvent` | `mdl/backend/modelsdk/microflow_write.go` (`microflowObjectToGen`) — compare against `sdk/mpr/writer_microflow.go`, which handles the full set | Add the missing `case *microflows.XxxEvent:` returning the gen element with ID, position and size. **Diagnose without Studio Pro**: dump the microflow (`mxcli bson dump --type microflow --object M.F`), collect every `$ID`, and check every key ending in `Pointer` resolves — a dangling pointer is this bug. When a check-time rule exists only as a stopgap for a write-path bug (MDL051 was), remove it with the real fix, and check whether it covered every affected keyword (MDL051 covered `break`, not `continue`, which is how #791 reached users). Repro `mdl-examples/bug-tests/791-loop-continue-dangling-flow.mdl`. Issue #791, ledger #52 | -| `mxcli test` leaves the project mutated: Security Level changed, after-startup pointing at the deleted `MxTest.TestRunner`, and only a `Warning:` line about it. Or: an empty `MxTest` module accumulates after every run | Three defects in one teardown. (a) `getAfterStartup` trimmed quotes *before* trailing punctuation, so a DESCRIBE SETTINGS line ending in `,` yielded `Module.Flow',` and the restore statement was unparseable; (b) cleanup dropped only the microflow, not the module it created; (c) the Security Level was forced OFF and restored to a hardcoded PRODUCTION | `cmd/mxcli/testrunner/runner.go` (`parseSettingValue`, `quoteMDLString`, `projectState`, `setupCommands`, `cleanupCommands`) | Strip trailing `,;` before unquoting; re-emit via `quoteMDLString` (doubling embedded quotes, never backslashes); capture a `projectState` before the first mutation and restore from it; drop the module only when the run created it (a pre-existing `MxTest` is the user's); leave Security Level alone entirely; return cleanup errors instead of printing warnings, and fail the run when they occur. Command lists are pure functions so the restore is testable without a project or Docker. Issues #802/#803/#804 | -| `create or modify external entities` silently resets a per-entity setting the user had changed (e.g. allow-create-change-locally) | `applyExternalEntityFields` stamps every field on both the create and the update path, so anything not derivable from the OData contract was overwritten with a default | `mdl/executor/cmd_contract.go` (`applyExternalEntityFields`) | Separate contract-derived fields (Countable/Creatable/Deletable/Skip/Top — refresh from metadata) from local modelling choices (CreateChangeLocally — leave alone; a new entity arrives zero-valued, which is Mendix's default). Issue #782 | -| An **external (OData) entity** loses its remote settings on any read-modify-write — `describe external entity` says `is not an external entity (source: )`, and `alter entity … set allow_create_change_locally = true` reports success but the flag stays off. Works under `--engine legacy` | `entityFromGen` recognised only `DomainModels$OqlViewEntitySource`, so the three `Rest$OData*` sources read back as no source at all: `Source` empty, every remote field zeroed. The write path was fine — it switches on `e.Source`, which the read never populated | `mdl/backend/modelsdk/domainmodel.go` (`entityFromGen`'s source switch, `odataKeyFromGen`) | Mirror the legacy parser (`sdk/mpr/parser_domainmodel.go`) for all three flavours: RemoteEntitySource (capabilities + CreateChangeLocally + key), EntityTypeSource (type name + IsOpen + key), PrimitiveCollectionEntitySource (service only). `Updatable` has no gen accessor and the writer does not emit it — leaving it zero is symmetric. **Check the read side first when a write-path field "does not stick"**: a switch on a field the read never fills looks like a write bug. Repro `mdl-examples/bug-tests/782-external-entity-create-change-locally.mdl`. Issue #782 | -| A pluggable widget's datasource (or child widget, or client action) is **silently dropped at write time** — `exec` prints `Created page` but the widget lands with the piece missing, and only a `log` line mentions `not yet supported — rerun with MXCLI_ENGINE=legacy` | The converter *does* return an error, but it travels through `widgetobj.ChildSerializer`, whose methods return BSON with no error channel (the `TODO(shared-types)` in `mdl/backend/widgetobj/builder.go`), so the caller logged it and returned nil | `mdl/backend/modelsdk/widget_pluggable_write.go` (`recordChildSerializeErr`, `takeChildSerializeErr`) + the drains in `page_write.go` / `snippet_write.go` | Record the failure in a package-level accumulator and drain it at every page/snippet write entry point, so the statement fails instead of the write succeeding with data missing (ADR-0004: refuse, don't drop). The real fix is the deferred `ChildSerializer` interface change; until then, any **new** write entry point that builds pluggable widgets must drain too. Repro `mdl-examples/bug-tests/795b-flow-datasource-context-entity.mdl` | -| `describe page` reports the wrong context entity under a data container bound to a microflow/nanoflow — `-- Context: $currentObject (Module.GetOrders)` names the *flow* instead of the entity it returns | `widget.EntityContext = widget.DataSource.Reference` is correct for a database source (reference *is* the entity) and wrong for a flow source (reference is the flow's qualified name) | `mdl/executor/cmd_pages_describe_flowcontext.go` (`dataSourceEntityContext`, `flowReturnEntity`) + the five assignment sites in `cmd_pages_describe_parse.go` | Resolve the flow's return type via `ListMicroflows`/`ListNanoflows` + `getHierarchy().GetQualifiedName`, taking the entity from an Object/List return type; fall back to the reference when the flow is unresolvable or returns a scalar, so the result is never worse than before. Note `GetRawUnitByName` is unimplemented on the modelsdk engine — the list+hierarchy path is the one that works | -| `describe page` omits a **pluggable** widget's `DataSource` when it is bound to a microflow (`datagrid g1 {` with no DataSource), while a `database from` source describes fine — re-applying the output recreates the grid unbound | A `Forms$MicroflowSource` stores the name in the nested `Forms$MicroflowSettings` (`MicroflowSettings` → `Microflow`), which is what the write path and Studio Pro emit; the reader looked up a top-level `Microflow` key, got `""`, and returned no datasource. The describe *formatter* was correct all along — read bug only | `mdl/executor/cmd_pages_describe_pluggable.go` (`microflowSourceRef`, `nanoflowSourceRef`, `extractDataGrid2DataSource`, `extractGalleryDataSource`, `parseCustomWidgetDataSource`) | Read the nested settings with a top-level fallback, via one shared helper — there were four divergent copies of this lookup and two were wrong. `Forms$NanoflowSource` was missing entirely from the DataGrid2/Gallery switches; add it alongside. Do **not** touch `CustomWidgets$CustomWidgetNanoflowSource` (a different metamodel type whose `Nanoflow` really is top-level) or the `Forms$MicroflowAction` reads (actions, not datasources). Repro `mdl-examples/bug-tests/795-datagrid-microflow-datasource-describe.mdl`. Issue #795 | -| Any `ALTER SETTINGS` (any section) reports success but the Default configuration's **Custom settings** are gone, **Tracing** is reset, and every **constant override** shows blank in Studio Pro — Integer/Long constants then fail the build | The configuration was re-serialized from `model.ServerConfiguration`, which carries only the modelled fields, so CustomSettings/Tracing/OpenAdminPort/OpenHttpPort were dropped, the list version markers downgraded 3→2, and overrides were written with a flat `Value` instead of the nested `SharedOrPrivateValue` the platform reads | `mdl/settingsoverlay/settingsoverlay.go` (`Configurations`, `ServerConfiguration`, `ConstantValues`) — called by both `mdl/backend/modelsdk/settings_write.go` and `sdk/mpr/writer_settings.go` | Overlay onto the raw document instead of rebuilding: write only the fields the read path populates, take each list's marker from what is stored, and update a constant override in the slot it already occupies (nested if nested, flat if flat). New override → nested. New configuration → clone a sibling's shape, empty its collections, mint a fresh `$ID`. Refuse the write when `RawParts` is empty. Repro `mdl-examples/bug-tests/801-alter-settings-preserves-configuration.mdl`. Issue #801 | -| `ALTER SETTINGS` / `CREATE CONFIGURATION` prints "Updated …" but `DESCRIBE SETTINGS` shows the old value — an Integer property was given a non-numeric value, or a Boolean anything other than `true` | `strconv.Atoi`'s error was discarded (`if v, err := …; err == nil`) so the assignment was skipped while the caller still printed success; the boolean form compared against `"true"`, silently mapping every other spelling to false | `mdl/executor/cmd_settings.go` (`settingsInt`, `settingsBool`) + `mdl/executor/validate_settings.go` (`typedSettingsKeys`, MDL-SET01/MDL-SET02) | Parse through a helper that returns a validation error naming the setting and the offending value, and register the property in `typedSettingsKeys` so `mxcli check` and the LSP flag it before the project is opened for writing. `TestTypedSettingsKeys_MatchExecutor` guards the table against drifting from the executor's switch. Repro `mdl-examples/bug-tests/805-alter-settings-typed-values.fail.mdl`. Issue #805 | -| MCP op reports `MCP error -32000: Request timed out` but the page/document/entity EXISTS in Studio Pro afterwards | Studio Pro's ~30s server-side per-call limit fires while the op still applies — a client false failure, not a server rejection | `mdl/backend/mcp/timeout.go` (`isTimeoutErr`, `timeoutVerifyDelay`, `pedUpdateVerify`, `pedDocumentExists`) | Verify-on-timeout, never blind-retry a non-idempotent op: idempotent root-replace (`pgWritePage`) retries once; creates confirm via `ped_find_document`; entity adds confirm via a shallow `/entities` read. Unverified → error with save-before-re-run guidance (a blind re-run from a fresh session duplicates elements) | -| Retrieve/datasource XPath with a `[%…%]` token (e.g. `[System.owner = '[%CurrentUser%]']` or `[Title = '[%CurrentUser%]']`) fails `mx check` CE0161, but `[Title='abc']` is clean | NOT a token-storage bug — tokens store intact and a type-valid token (`[DueDate < '[%CurrentDateTime%]']`) passes. The failures are semantically-invalid XPath: (1) String/scalar attr compared to a User token = type mismatch; (2) `System.owner`/`changedBy`/… referenced on an entity that doesn't store it (needs `alter entity X add attribute owner: autoowner`) | `mdl/executor/validate.go` (`validateRetrieveConstraints`, `baseSystemMemberRe`) — diagnose with `mxcli bson dump --type microflow` + `mx check`; verify the token alone works | Don't "fix" storage — it's correct. Add a `--references` check: collect retrieve `(entity, constraint)` in `flowRefCollector`, look up the entity via `buildEntityIndex` (`ListDomainModels`), and flag a base-entity `System.` ref (regex excludes `/`-traversed refs) when the entity flag (`HasOwner` etc.) is off, with the `alter entity … add attribute …: auto…` hint. Same-script-created entities aren't in the project index, so the check only fires against existing project entities. **Also fixed**: a bare `[%token%]` *inside* a bracketed constraint (`[DueDate < [%CurrentDateTime%]]`) stored unquoted (the inline path keeps the raw source) → CE0161. `normalizeXPathTokens` (`mdl/visitor/visitor_page_v3.go`) requotes bare tokens; wired into `buildXPathSourceExpression`, the multi-predicate `predicateSources`, `buildXPathString`, and `bracketedXPathFromExpr` (already-quoted tokens untouched). Issue #641 | -| `describe` shows `$var = list operation ...;` | Missing parser case | `sdk/mpr/parser_microflow.go` → `parseListOperation()` | Add `case "microflows$XxxType":` returning the correct struct | -| `describe` shows `$var = action ...;` | Missing formatter case | `mdl/executor/cmd_microflows_format_action.go` → `formatActionStatement()` | Add `case *microflows.XxxAction:` with `fmt.Sprintf` output | -| `describe` shows `$var = list operation %T;` (with type name) | Missing formatter case | `mdl/executor/cmd_microflows_format_action.go` → `formatListOperation()` | Add `case *microflows.XxxOperation:` before the `default` | -| Compile error: `undefined: microflows.XxxOperation` | Missing SDK struct | `sdk/microflows/microflows_actions.go` | Add struct + `func (XxxOperation) isListOperation() {}` marker | -| `TypeCacheUnknownTypeException` in Studio Pro | Wrong `$type` storage name in BSON write | `sdk/mpr/writer_microflow.go` | Check the storage name table in CLAUDE.md; verify against `reference/mendixmodellib/reflection-data/` | -| A `create page` reported success but the built page is EMPTY — every widget gone — and `mx check` fails with CE1613 "The selected layout 'dummyModule.dummyName' no longer exists" | The page had no `Layout:` clause, so `buildPageV3` created no `LayoutCall`; the widget tree is built into the LayoutCall's placeholder arguments, so with no LayoutCall the widgets have nowhere to attach and are silently dropped. `dummyModule.dummyName` is *Mendix's* placeholder for a missing layout, not something mxcli writes | `mdl/executor/cmd_pages_builder_v3.go` (`buildPageV3`, the `if page.LayoutCall != nil` block) | Reject a page that has body widgets (or placeholder blocks) but no LayoutCall — distinguish "no Layout: clause" from "layout not found" in the message. A Mendix page always needs a layout; snippets (buildSnippetV3) are layout-less and unaffected. Repro `mdl-examples/bug-tests/266-page-without-layout-drops-widgets.mdl` | -| `ALTER PAGE INSERT`/`REPLACE` into a list bound `from association` **or** `datasource: microflow/nanoflow` produces a widget whose Attribute binds to the WRONG entity (the outer data view's) or nothing — `mxcli check` ✓ but `mx check` fails **CE1613** ("attribute no longer exists") or **CE0402** ("No value specified"); DESCRIBE masks it by printing only the short attribute name | The ALTER mutator read the enclosing entity from `DataSource.EntityRef.Entity`, only set for a DIRECT ref (database). An `AssociationSource` stores its destination on the last `DomainModels$EntityRefStep` of an `IndirectEntityRef`; a `MicroflowSource`/`NanoflowSource` stores NO entity at all (its entity is the flow's RETURN type, in the flow document). Either way the list reported no entity, so the context stayed at the outer data view (or empty) | `mdl/backend/pagemutator/mutator.go` (`extractEntityFromDataSource`+`lastStepDestinationEntity` for association; `EnclosingDataSourceFlow`+`findNearestDataSourceDoc` for flows) + `mdl/executor/cmd_alter_page.go` (`resolveDataSourceFlowEntity`) | Association: read the `IndirectEntityRef`'s last `EntityRefStep.DestinationEntity`. Microflow/nanoflow: the mutator returns the nearest-enclosing (or own, for INTO) flow QN — a nearer non-flow source shadows an outer flow — and the executor resolves its return entity via `getMicroflowReturnEntityName`/`getNanoflowReturnEntityName`. Guards `TestEnclosingEntity_AssociationSource`, `TestEnclosingDataSourceFlow`; repro `mdl-examples/bug-tests/55-alter-page-insert-assoc-binding.mdl`. FINDINGS #55 | -| `mx check` fails to LOAD the project — `StorageLoadException: ... 'Module.Name' is not a valid AttributeIdentifier` after a `create`/`change` with a `Module.Assoc = …` member, yet `mxcli exec` reported success | A one-qualifier member (`Module.Name`) that isn't a known association was written as an *attribute* ref, but a one-qualifier name can't be a valid attribute (attributes are bare or `Module.Entity.Attribute`) → unloadable .mpr. Usually the association's `create` failed earlier (non-idempotent) leaving it absent | `mdl/executor/cmd_microflows_builder_actions.go` (`resolveMemberChange`, the "Not an association in the authored module" branch) | When the domain model is available and the one-dot member isn't in `dm.Associations`/`dm.CrossAssociations`, `fb.addError` with an actionable "create the association first" message instead of writing an Attribute. Same-script associations are visible via `GetDomainModel`, so no false positive. Repro `mdl-examples/bug-tests/264-create-member-unknown-association.mdl`. FINDINGS #51 | -| Runtime `Failed to load model: ... Class 'Workflows$CallMicroflowTask' could not be found` — the WHOLE app won't boot, yet `mxcli check` ✓ and `mx check` → 0 errors | Mendix 11.9 (WOR-2802) split MicroflowBasedActivity into CallMicroflowActivity + AIAgentTaskActivity, renaming the workflow call-microflow on-disk `$Type` from the pre-11.9 `CallMicroflowTask` to `CallMicroflowActivity`. Writing the old name to an 11.9+ project is fatal at boot only. Evidence: 11.6.3 modeler = only Task; 11.10 modeler = both (Task marked "Removed ... WOR-2802" + a conversion routine); 11.10+ runtime jars = only Activity | `mdl/backend/modelsdk/workflow_write.go` (`applyCallMicroflowStorageName`, `useCallMicroflowActivityName`) + legacy `sdk/mpr/writer_workflow.go` (`renameCallMicroflowTypeBSON`) | Version-gate the emitted `$Type` at 11.9 (same boundary as the `HasOwner`→`HasOwnerAttr` gate): build with the legacy name, rewrite the tree to `CallMicroflowActivity` for `pv.IsAtLeast(11,9)`; register codec TypeDefaults + list-marker under BOTH names; read path already folds both into one semantic type. Repro `mdl-examples/bug-tests/263-workflow-callmicroflow-storage-name.mdl`. FINDINGS #39 | -| `mxcli check` rejects a **valid** microflow: **MDL048** on `retrieve … where [id = '[%CurrentUser%]']` (the standard signed-in-user idiom) — but `mx check` → 0 errors | MDL048 targets constraining `id` against a STORED value (String/Long var or plain literal), which Mendix XPath can't do; it also matched the `'[%CurrentUser%]'` **server token**, which Mendix DOES resolve to a GUID | `mdl/executor/validate_microflow.go` (`checkXPathIdConstraint`) | Skip an operand of the form `'[%…%]'` (a resolved token) before flagging. Case still fires for real stored-id values. Test `TestValidateMicroflow_XPathIdConstraint` (CurrentUser case); repro `mdl-examples/bug-tests/52-53-microflow-check-false-positives.mdl`. FINDINGS #53 | -| `mxcli check` rejects a **valid** microflow: **MDL045** ("`/` is division") on `round($a div $obj/Attr * 100)` — division whose divisor is an association-attribute path — but `mx check` → 0 errors | The MDL grammar parses `div`/`*`/`/` at one precedence level, so `$a div $obj/Attr` mis-nests as `($a div $obj) / Attr`; MDL045 saw the `/ Attr` as division. But `Attr` is a bare member name — Mendix has no `/` division operator and re-parses the raw `$obj/Attr` as a path (serialized output preserves the `/`, so the build is clean) | `mdl/executor/validate_microflow.go` (`exprHasSlashDivision`) | Don't flag a `/` BinaryExpr whose RIGHT operand is a bare `IdentifierExpr` (member navigation); real division has a numeric/paren/variable divisor. Test `TestValidateMicroflow_SlashDivision` (div-by-assoc cases); repro `mdl-examples/bug-tests/52-53-microflow-check-false-positives.mdl`. FINDINGS #52 | -| `describe microflow` prints `-- Empty action` for a `set task outcome` / `open user task` / `notify workflow` statement (default engine); a describe→drop→exec round-trip silently drops it. Legacy engine (`MXCLI_ENGINE=legacy`) describes it fine | The modelsdk read path (`actionFromGen`) had no case for the workflow microflow actions, so they read back as nil → "Empty action". The write path + DESCRIBE formatter already handled them; only the modelsdk read case was missing | `mdl/backend/modelsdk/microflow_read_actions.go` (`actionFromGen`) | Add cases for `genMf.SetTaskOutcomeAction` / `OpenUserTaskAction` / `NotifyWorkflowAction`, mirroring the legacy parsers. Test `TestActionFromGen_WorkflowActions`; repro `mdl-examples/bug-tests/54-describe-set-task-outcome.mdl`. FINDINGS #54 | -| `create association X …` errors "association already exists" on re-run and aborts the script | Correct SQL-shaped semantics (like `CREATE TABLE`) — `create` is not idempotent. The idempotent form is `create or modify association`, but it was undiscoverable from the bare error | `mdl/executor/cmd_associations.go` (the `NewAlreadyExists("association", …)` sites) | Not a code bug in the write path — improve the error to name `create or modify association …` and `drop association …`. Repro `mdl-examples/bug-tests/51-create-or-modify-association.mdl`. FINDINGS #51 | -| `dynamictext x (Content: '')` builds with **CE0720** "Place holder index 1 is greater than 0" — `mxcli check` ✓, describe shows `Content: '{1}'` with no params | The builder unconditionally defaulted empty content to the template `{1}`, creating a placeholder with no matching parameter (orphaned) | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildDynamicTextV3`, final `content == ""` guard) | Only default to `{1}` when there IS a parameter (`autoGeneratedParams`/`explicitParams`); empty content with no params is a literal empty template. Test `TestBuildDynamicTextV3_EmptyContent`; repro `mdl-examples/bug-tests/traceops-9-10-17-dynamictext-listview.mdl`. traceops #9 | -| `dynamictext s (Content: '$318')` builds with **CE0402/CE1613** ("attribute '$318' no longer exists") — the literal was turned into an unbound `{1}` param | The auto-bind check treated ANY `$`-prefixed content as a variable; `$318` (dollar + digits) is not a valid Mendix variable | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`isDynamicTextVariableRef` / `dynamicTextVariableRe`) | Treat `$` as a variable ONLY when followed by a letter/underscore (`^\$[A-Za-z_]`); `$318` stays literal content. Tests `TestBuildDynamicTextV3_DollarDigitLiteral`, `TestIsDynamicTextVariableRef`. traceops #10 | -| `listview lv (… PageSize: 200)` always pages at 20 — `mxcli check` ✓, `mx check` ✓, describe shows no PageSize | The property parsed into the AST but three layers ignored it: `buildListViewV3` hardcoded `PageSize: 20`, the describe parse never read it, and the listview describe formatter never emitted it | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildListViewV3`) + `cmd_pages_describe_parse.go` (Forms$ListView case) + `cmd_pages_describe_output.go` (listview case) | Read `w.GetIntProp("PageSize")` on write; read `w["PageSize"]` on describe; emit a non-default PageSize in the listview formatter. Test `TestBuildListViewV3_PageSize`. traceops #17 | -| `mxcli oql -p app.mpr "…"` fails against a `mxcli run --local` app — first `admin password required`, then (with the password) `OQL error: Action not found.` and 0 rows — even though the same query works under `mxcli docker up` | Two-part gap. (a) The local runtime booted the JVM with no system properties, so `/dev/preview_execute_oql` was never mounted (docker mode passes `-Dmendix.live-preview=enabled -Dmendix.running.locally.by.studiopro=true` via docker-compose; the local boot did not). (b) `run --local` never printed or wrote the admin password, and `mxcli oql` errored instead of defaulting to it | `cmd/mxcli/docker/localboot.go` (`LocalRuntimeOptions.jvmArgs` — new) + `cmd/mxcli/docker/m2ee.go` (`resolveM2EEDefaults` token fallback) + `cmd/mxcli/docker/oql.go` (Action-not-found hint) + `cmd/mxcli/docker/runlocal.go` (banner) | Always pass the two live-preview `-D` flags in `jvmArgs` (run --local is always DTAPMode=D, matching docker); default the oql admin token to `defaultLocalAdminPass` when nothing else supplies it (admin API is loopback-only); branch the "Action not found" hint to cover the `--local` case; print a `Query data: mxcli oql …` line in the run banner. Tests `TestJVMArgs`, `TestResolveM2EEDefaults_Defaults`. traceops #36 | -| A custom Starlark lint rule can't detect a **microflow-datasource** widget (e.g. a ListView with no DB pushdown) — `widget.microflow_ref` is unavailable, so a rule keyed on it returns zero hits at any threshold, even though `CATALOG.WIDGETS.MicroflowRef` is populated | The linter's `Widget` projection (struct + `Widgets()` query + Starlark dict) dropped `MicroflowRef`/`NanoflowRef` — the catalog records them but they never reached the rule tier | `mdl/linter/context.go` (`Widget` struct, `Widgets()` SELECT+Scan) + `mdl/linter/starlark.go` (`widgetToStarlark`) | Carry `MicroflowRef`/`NanoflowRef` through from the catalog and expose `microflow_ref`/`nanoflow_ref` on the Starlark widget struct (mirroring `entity_ref`). Doc: `.claude/skills/mendix/write-lint-rules.md` widget field table. Test `TestWidgets_ProjectsMicroflowNanoflowRef`. traceops #35 | -| A `/** … */` doc comment between `alter entity … add attribute` clauses is a **parse error** (`no viable alternative at input '/**'`) | `alterEntityAction` accepted a doc comment only INSIDE an `attributeDefinition` (after the ADD ATTRIBUTE keyword), not between clauses. `--` line comments are NOT an equivalent workaround — they are discarded, whereas a `/** */` doc comment is persisted as the attribute's Mendix documentation | `mdl/grammar/domains/MDLDomainModel.g4` (`alterEntityAction`) + `mdl/visitor/visitor_entity.go` (`ExitAlterEntityAction` ADD branch) | Add `docComment?` before `ADD ATTRIBUTE`/`ADD COLUMN`; the visitor attaches it as the added attribute's documentation when the attributeDefinition has none. `make grammar` regenerates the parser (not committed). Test `TestAlterEntityAddAttributeDocComment`; repro `mdl-examples/bug-tests/traceops-27-doc-comment-between-clauses.mdl`. traceops #27 | -| `combobox (Association: Mod.Ref, …)` drops the binding — `mxcli check` ✓ but MxBuild fails **CE0642** "Property 'Attribute' is required" | The widget engine's `Association` source read the reference only from the `attribute:` keyword (`w.GetAttribute()`), so an explicit `Association:` keyword was ignored and the widget fell back to enumeration mode | `mdl/executor/widget_engine.go` (`case "Association"`) + `mdl/executor/validate_widgets.go` (`validateComboBoxAssociation`) | Read the reference from `Association:` OR `attribute:`; and add MDL-WIDGET16 flagging an association combobox that lacks the required `datasource:` (option list). A complete association combobox needs reference + `datasource:` + `CaptionAttribute:`. Tests `TestValidateComboBoxAssociation`; repro `mdl-examples/bug-tests/traceops-23-combobox-association.mdl`. traceops #23 | -| A bare MDL keyword used as a WIDGET name (`container body`, `dynamictext content`) is a parse error (`mismatched input 'body' expecting {IDENTIFIER, QUOTED_IDENTIFIER}`) | `widgetV3`'s name only accepted `IDENTIFIER \| QUOTED_IDENTIFIER`, not `keyword` — unlike `attributeName`/placeholder names | `mdl/grammar/domains/MDLPage.g4` (`widgetV3`) + `mdl/visitor/visitor_page_v3.go` (`buildWidgetV3` name extraction) | Add `keyword` to the widget-name alternatives; the visitor reads `wCtx.Keyword()` too. `make grammar` regenerates the parser. Test `TestKeywordWidgetName`; repro `mdl-examples/bug-tests/traceops-11-12-16-strings-names.mdl`. traceops #12 | -| A `'…'` string literal spanning multiple lines fails to parse (`missing END at '…'`) — the newline terminated the token | `STRING_LITERAL` excluded `\r\n` (`~['\r\n\\]`) | `mdl/grammar/MDLLexer.g4` (`STRING_LITERAL`) | Drop `\r\n` from the exclusion (`~['\\]`); mxbuild accepts a multi-line String value (verified). A missing close-quote now spans lines — the standard multi-line-string trade-off. Test `TestMultiLineStringLiteral`. traceops #11 | -| `alter entity … add : ` (missing the `attribute` keyword) fails with an opaque `no viable alternative at input 'add'` | ALTER ENTITY requires the `attribute` keyword (SQL-shaped); the raw ANTLR error doesn't say so | `mdl/visitor/visitor.go` (`enhanceErrorMessage` / `addMissingAttributeRe`) | Source-aware hint: when the offending line is `add :` and `` isn't a real clause keyword (attribute/column/index/event/value/…), append the correct `add attribute : ` form. Gated to the primary "no viable alternative" error. Test `TestAlterEntityMissingAttributeKeywordHint`. traceops #16 | -| CE0066 "Entity access is out of date" | MemberAccess added to wrong entity | `sdk/mpr/writer_domainmodel.go` | MemberAccess must only be on the FROM entity (`ParentPointer`), not the TO entity — see CLAUDE.md association semantics | -| `grant view on page` / `grant execute on microflow\|nanoflow` / `grant access on odata\|published rest service` to a role from **another module** passes `mxcli check`/`exec` but fails the Mendix build with **CE0148 "reselect roles"** — the own-module role works | Document access (page/microflow/nanoflow/service `AllowedModuleRoles`) may only reference the document's **own** module roles; Studio Pro's picker only offers those. The grant path wrote `role.Module + "." + role.Name` verbatim with no same-module check (only `validateModuleRole` = role-exists-in-its-module), so a cross-module reference reached the model. The MOVE path already guarded this (`remapDocumentAccessRoles`) — GRANT didn't | `mdl/executor/cmd_security_defaults.go` (`checkDocumentAccessRolesSameModule`) + the 5 grant handlers in `mdl/executor/cmd_security_write.go` | Pre-check each grant: reject when any `role.Module != docModule` with an actionable message (name the doc's module + suggest the own-module role). Reject (don't silently remap) — a GRANT is explicit, so a wrong role/doc shouldn't be substituted. Wired into page/microflow/nanoflow/OData/published-REST grants. Repro `mdl-examples/bug-tests/ce0148-cross-module-grant.mdl` | -| CE0463 "widget definition changed" | Object property structure doesn't match Type PropertyTypes | `sdk/widgets/templates/` | Re-extract template from Studio Pro; see `sdk/widgets/templates/README.md` | -| Parser returns `nil` for a known BSON type | Unhandled `default` in a `parseXxx()` switch | `sdk/mpr/parser_microflow.go` or `parser_page.go` | Find the switch by grepping for `default: return nil`; add the missing case | -| MDL check gives "unexpected token" on valid-looking syntax | Grammar missing rule or token | `mdl/grammar/MDLParser.g4` + `MDLLexer.g4` | Add rule/token, run `make grammar` | -| CE7054 "parameters updated" / CE7067 "does not support body entity" after `send rest request` | `addSendRestRequestAction` emitted wrong BSON: all params as query params, BodyVariable set for JSON bodies | `mdl/executor/cmd_microflows_builder_calls.go` → `addSendRestRequestAction` | Look up operation via `fb.restServices`; route path/query params with `buildRestParameterMappings`; suppress BodyVariable for JSON/TEMPLATE/FILE via `shouldSetBodyVariable` | -| `CREATE X` returns "already exists — use create or replace to overwrite" but OR REPLACE is not valid for that type | Error message in executor points to wrong keyword | `mdl/executor/cmd__*.go` — find the `NewAlreadyExistsMsg` call | Change hint from `or replace` to `or modify`; verify the AST stmt uses `CreateOrModify` not `CreateOrReplace` | -| `mx check` CE0126 "Missing value for parameter X" on `call java action ... ($Param = empty)` for typed (non-entity, non-microflow) parameters | Builder emitted `BasicCodeActionParameterValue.Argument: ""` instead of the literal `"empty"` keyword | `mdl/executor/cmd_microflows_builder_calls.go` → `addCallJavaActionAction` | Capture all resolved BasicParameterType params into `resolvedBasicParams`; when bound to MDL `empty`, emit `Argument: "empty"` so Studio Pro recognises an explicit empty literal rather than treating the slot as missing | -| `DESCRIBE microflow` puts shared activities inside an `if … then` block — they should appear after `end if;` | Nested guard split inside `traverseFlowUntilMerge` crosses the outer merge boundary | `mdl/executor/cmd_microflows_show_helpers.go` — guard path in `traverseFlowUntilMerge` (~line 854) | Add `if contID != mergeID` guard before the `isMerge` skip-through so the guard continuation never crosses the outer merge | -| `DESCRIBE microflow` on the **modelsdk** engine renders a branching microflow as `if cond then end if;` — the entire then/when body is dropped (legacy engine renders it fully). Affects projects Studio Pro saved with the older single-case storage | The split's branch case is stored under the BSON storage name `NewCaseValue` (single child), but (1) the codec had no `CaseValue`→`NewCaseValue` alias so it was never decoded, and (2) the adapter read only the plural `CaseValuesItems()`, not the singular `CaseValue()` | `modelsdk/codec/decoder.go` (`fieldAliases`) + `mdl/backend/modelsdk/microflow.go` (`caseValueFromGen`/`mapGenCaseValue`) | Add `"CaseValue": "NewCaseValue"` to `fieldAliases` (mirrors `Type`↔`NewType`); read the singular `g.CaseValue()` first, then the plural list, mapping `EnumerationCase`/`InheritanceCase`. A round-trip MDL bug-test can't reproduce it (the write path emits the new format) — covered by codec + adapter unit tests | -| `DESCRIBE microflow` on the **modelsdk** engine renders a rule-based split as `if true then …` instead of the real `Module.Rule_X(args)` condition (body renders, only the condition text is wrong) | `splitConditionFromGen` only handled `*ExpressionSplitCondition`; a `RuleSplitCondition` returned nil → renderer fell back to `true`. Even when handled, the rule name lives under the `RuleCall.Microflow` storage key, but gen decodes the property as `Rule` (and `ByNameRef` decode bypasses `fieldAliases`), so `RuleQualifiedName()` is empty | `mdl/backend/modelsdk/microflow.go` (`splitConditionFromGen`) | Reconstruct `*microflows.RuleSplitCondition` from the gen `RuleCall`; read the rule name from `rc.Raw().Lookup("Microflow")` when `RuleQualifiedName()` is empty (same raw-storage-key pattern the action readers use). The existing `formatSplitCondition` renders it | -| `DESCRIBE microflow`/`nanoflow` on the **modelsdk** engine omits the `grant execute on microflow … to ;` line that legacy emits | `microflowFromGen`/`nanoflowFromGen` never read the allowed module roles, so `Microflow.AllowedModuleRoles` was empty and the describer skipped the grant block | `mdl/backend/modelsdk/microflow.go` (`microflowFromGen`/`nanoflowFromGen`) | Populate `out.AllowedModuleRoles` from the gen `AllowedModuleRolesQualifiedNames()` (BY_NAME role refs, stored as qualified-name strings) | -| `DESCRIBE microflow` on the **modelsdk** engine renders a loop `break;`/`continue;` as `-- Empty action` | `flowObjectFromGen` had no cases for `Microflows$BreakEvent` / `Microflows$ContinueEvent`, so they fell through to the ActionActivity default with a nil action | `mdl/backend/modelsdk/microflow.go` (`flowObjectFromGen`) | Add both object cases (renderer already maps them to `break;`/`continue;`) | -| `DESCRIBE microflow` on the **modelsdk** engine renders `export to mapping …` / `import from mapping …` as `-- Empty action`; and single-object mapping results render `as list of Entity` instead of `as Entity` | `actionFromGen` lacked `ExportXmlAction`/`ImportXmlAction` cases; `restResultHandlingFromRaw` set `SingleObject` only from `Range.SingleObject`, ignoring an object var type (REST) and `ForceSingleOccurrence` (XML import) | `mdl/backend/modelsdk/microflow_read_actions.go` (`actionFromGen`, `restResultHandlingFromRaw`, `readMappingCall`) | Add both XML-action cases reading mapping/arg/output from raw `ResultHandling`/`OutputMethod`/`ImportMappingCall`; extract `readMappingCall`; apply the object-type rule (REST) and force fallback (XML import) — mirrors legacy `parseExportXmlAction`/`parseImportXmlAction`/`parseResultHandling` | -| `DESCRIBE microflow` on the **modelsdk** engine drops a database retrieve's `sort by …` clause | `retrieveSourceFromGen` read Entity/XPath/Range but not the sort columns, which live in the `DatabaseRetrieveSource`'s `NewSortings` child | `mdl/backend/modelsdk/microflow_read_actions.go` (`retrieveSourceFromGen`, `sortItemsFromRaw`) | Generalize `sortItemsFromRaw` to accept the `NewSortings` wrapper key (it already handled the Sort list-op `Sortings` key); set `s.Sorting = sortItemsFromRaw(g.Raw())` | -| A database retrieve's `sort by …` is **completely missing** from DESCRIBE after any modelsdk **write** (regression on switching to the modelsdk engine; "no more sort") — reproduces on a fresh `create microflow … retrieve … sort by …` round-trip | The WRITE counterpart of the row above: `retrieveSourceToGen` set an **empty** `genMf.NewSortItemList()` and never serialized `s.Sorting`, so nothing was stored for the read to render. The generated `SortItemList` type also stores items under `"Items"`, but Mendix stores retrieve sort columns under `"Sortings"` (like the Sort list-op), so the gen convenience type is the wrong tool | `mdl/backend/modelsdk/microflow_write.go` (`retrieveSourceToGen`) | Build the envelope manually, mirroring the `SortOperation` branch: `newElem("Microflows$SortingsList","")` + `addPartList(sl,"Sortings", sortItemToGen(each))` + `g.SetSortItemList(sl)`. Guard with a toGen→encode→decode→fromGen round-trip test asserting the sort items survive. Note "completely missing" ⇒ write dropped it (nothing stored); a *blank-attribute* sort line ⇒ a read/name-resolution gap instead. Issue #727 | -| Consumed OData service **Configuration microflow** has no effect in Studio Pro ≥ 11.10 — the "Configuration source" dropdown stays on "Constants only" even though `describe odata client` shows the microflow | Mendix **renamed the BSON storage field** across versions (from the `Rest$ConsumedODataService` reflection metadata): `ConfigurationMicroflow` was introduced 10.12.0 and **DELETED 11.10.0**; `ConfigurationEntityMicroflow` was introduced 11.10.0. mxcli wrote the pre-11.10 key on all versions, so ≥ 11.10 Studio Pro ignored the unknown field. Both engines were affected (identical BSON). The codebase had flip-flopped this key (573→`ConfigurationEntityMicroflow`, then reverted) — the reflection `VersionInfos` Introduced/Deleted is the authority | `model.ODataConfigMicroflowBSONKey` (new, version-gated), wired into `mdl/backend/modelsdk/odata_write.go` (`consumedODataServiceToGen` via `Backend.configMicroflowKey()`) **and** `sdk/mpr/writer_odata.go` (`serializeConsumedODataService` via `w.reader.ProjectVersion()`) | Gate the write key on project major/minor (≥ 11.10 → `ConfigurationEntityMicroflow`). On read, coalesce **all** historical keys (`model.ODataConfigMicroflowBSONKeys()` in the legacy parser; `firstNonEmpty(ConfigurationEntity…, Configuration…, Headers…)` of the gen getters in `integration_read.go`) so a service authored by any version round-trips. When a storage name looks version-dependent, check `modelsdk/gen/*/version.go` `VersionInfos` before guessing. Issue #728 | -| Published OData service: `mx check` reports **CE5016** "Attribute X has type String(50), but is published as ." (empty published type) for every exposed attribute — modelsdk engine only (legacy slides by) | mxcli never wrote `ODataPublish$PublishedAttribute.EdmType`, the OData EDM type Studio Pro stores on every published attribute. Both engines omitted it, but legacy's `$Type` field order let Studio Pro recompute; modelsdk's order tripped the check. **The legacy output was non-canonical too — don't treat "legacy passes mx check" as "legacy is correct."** Found by diffing mxcli BSON vs a Studio-Pro-duplicated-and-fixed copy (the definitive method for these "empty field" checks) | `mdl/executor/cmd_odata.go` (`mendixAttrTypeToEdm`, `lookupEntityMembers`, `astEntityDefToModel`) + both writers (`publishedMemberToGen`, `serializePublishedMember`) + both reads | Add `EdmType` to `model.PublishedMember`; derive it from the attribute's Mendix type (inverse of `edmToDomainModelAttrType`: String→Edm.String, Integer→Edm.Int32, Long/AutoNumber→Edm.Int64, Decimal→Edm.Decimal, Boolean→Edm.Boolean, DateTime→Edm.DateTimeOffset, Binary→Edm.Binary); emit + read in both engines | -| Published OData service: `mx check` reports **CE5022** "Published association X has changed multiplicity" for every exposed association — modelsdk engine only | Same shape as CE5016 but for `ODataPublish$PublishedAssociationEnd.IsMany` (the exposed navigation's multiplicity), which mxcli never wrote. Confirmed via the same Studio-Pro-duplicate-and-diff method | `mdl/executor/cmd_odata.go` (`assocMembership.Type`, `astEntityDefToModel`) + both writers/reads | Add `IsMany` to `model.PublishedMember`; compute from the association type + exposed side (ReferenceSet ⇒ to-many either end; Reference ⇒ to-many only from the TO/Child side, to-one from the FROM/Parent side); emit + read in both engines | -| `DESCRIBE microflow` on the **modelsdk** engine renders an inheritance split header as `split type $` (empty variable) and drops its `@caption` | `flowObjectFromGen` created a bare `InheritanceSplit` without reading `SplitVariableName`/`Caption`/`Documentation` off the gen element | `mdl/backend/modelsdk/microflow.go` (`flowObjectFromGen`) | Populate `VariableName`/`Caption`/`Documentation` from the gen `InheritanceSplit` accessors | -| `mx check` **CE4899** "concurrent execution: error message or microflow required" after a microflow round-trip (`GetMicroflow` → `UpdateMicroflow`), even though the microflow allowed concurrent execution | `microflowFromGen` never read `AllowConcurrentExecution`/`MarkAsUsed` back (the write path set them), so the round-trip reset them to `false` → "disallow concurrent execution" with no error message configured | `mdl/backend/modelsdk/microflow.go` (`microflowFromGen`) | Read both flags back from the gen getters (`mf.AllowConcurrentExecution()`, `mf.MarkAsUsed()`) — keep the FromGen converter lossless. Guarded by a `toGen→encode→decode→fromGen` Go test, not MDL (create-or-modify rebuilds the microflow from MDL, so it can't reproduce a read-back bug). Issue #723 A1 | -| Studio Pro renders **every activity/decision as a 1-px sliver** (caption wraps one letter per line) after a modelsdk round-trip; `mx check` reports **NO** error (it ignores box size, so the corruption is silent) | `flowObjectFromGen` carried each object's `Position` but not its `Size`, so the round-trip rewrote every node with size `0;0` | `mdl/backend/modelsdk/microflow.go` (`sizeFromGen`, `splitFlowObjects`) | Add `sizeFromGen` (mirrors `pointFromGen`) and apply it at the `splitFlowObjects` call site — covers nested loop bodies via recursion; add `GetSize`/`SetSize` to `BaseMicroflowObject`. Go round-trip test (not MDL, same reason as A1). Issue #723 A2 | -| `mx check` **CE0117** "Error in expression" when creating a rule-based decision (`if Module.SomeRule(...)`) via MDL on the **modelsdk** engine; the decision's subtype is demoted Rule→Expression on every round-trip | modelsdk `*Backend` never implemented `IsRule` → fell back to the embedded `unimplemented` stub (which errors); the builder's `isRule, err := backend.IsRule(...); if err != nil || !isRule` guard treated the error as "not a rule" and emitted an invalid `ExpressionSplitCondition` | `mdl/backend/modelsdk/microflow.go` (`IsRule`) | Implement `IsRule` on `*Backend` (list `Microflows$Rule` units, match qualified name via `moduleNameFor`), mirroring the legacy reader; it shadows the generated stub. With the existing builder mock-test this guards the full chain modelsdk.IsRule→RuleSplitCondition. Issue #723 A4 | -| `create enumeration … ("Value" = 'Caption')` fails with cryptic `mismatched input '=' expecting ')'`; user blames the *quotes* | Not a quoting bug — enum value names quote fine (`enumValueName` accepts `QUOTED_IDENTIFIER`). The `=` is invalid: MDL enum values are `Value 'Caption'` (or `Value caption 'Caption'`), no equals sign | `mdl/visitor/visitor.go` (`enhanceErrorMessage`/`looksLikeEnumEquals`) | Add an error hint keyed on `mismatched input '=' expecting ')'` (specific to enum value lists — attribute-default `=` gives a different message) pointing at the `=`. Clarify in `check-syntax.md` that captions use `Name 'Caption'`, never `=` | -| A genuine parse error on a short lowercase MDL keyword/identifier (`mismatched input 'on'`, `'in'`, `'as'`, `'to'`, `'by'`, …) is misdiagnosed with the "unescaped apostrophe" hint, sending the user to look for a quote that isn't there | `looksLikeUnescapedApostrophe` matched **any** 1–4 char lowercase token as a contraction leftover, so real short keywords tripped it | `mdl/visitor/visitor.go` (`looksLikeUnescapedApostrophe`, `contractionSuffixes`) | Match only the fixed contraction-suffix set (`s`/`t`/`d`/`m`/`re`/`ve`/`ll` — the leftovers from it's/don't/he'd/I'm/you're/we've/you'll), not arbitrary short lowercase words. Real apostrophe errors still hint; `on`/`in`/`as`/`to`/`by` no longer do. Findings #4 | -| Docs/skills document MDL that doesn't parse (drift): `ALTER ENTITY X ADD (a, b)` / `DROP (a)` / `MODIFY (a)` / `RENAME a TO b`, `ALTER ENUMERATION … REMOVE VALUE`, `CREATE CONSTANT … TYPE X;` (no default), `DELETE_BEHAVIOR ` | Docs were written against a SQL-DDL mental model, never `mxcli check`-validated. Correct forms: `ADD ATTRIBUTE a: type` (one action/statement), `DROP ATTRIBUTE a`, `MODIFY ATTRIBUTE a: type`, `RENAME ATTRIBUTE a TO b`, `DROP INDEX `; `DROP VALUE`; enum `ADD VALUE X CAPTION 'y'` (CAPTION keyword required for ALTER, unlike CREATE); a constant `DEFAULT` is mandatory; delete-behavior ∈ {DELETE_AND_REFERENCES, DELETE_BUT_KEEP_REFERENCES, DELETE_IF_NO_REFERENCES, CASCADE, PREVENT} | `scripts/check-skill-mdl.sh` + `make check-skill-mdl` (CI) | The guard extracts DDL statements from `.claude/skills/mendix/` **and** `docs-site/src/` and runs `mxcli check`; keeps this class of drift from recurring. Run it after editing any MDL example | -| `DESCRIBE microflow` on the **modelsdk** engine renders `download file $X …;` as `-- Empty action` | `actionFromGen` lacked a `DownloadFileAction` case | `mdl/backend/modelsdk/microflow_read_actions.go` (`actionFromGen`) | Add the case reading `FileDocumentVariableName()`/`ShowFileInBrowser()`, defaulting an empty `ErrorHandlingType` to Rollback. Note: the storage key is `ShowFileInBrowser` (legacy's `parseDownloadFileAction` reads the wrong `ShowInBrowser` key — a latent legacy bug; the gen reads it correctly) | -| `DESCRIBE microflow` on the **modelsdk** engine renders a legacy SOAP `call web service …` as `-- Empty action` | `actionFromGen` lacked a `WebServiceCallAction` case | `mdl/backend/modelsdk/microflow_read_actions.go` (`actionFromGen`, `webServiceActionRequiresRawBSON`) | Add the case: read the structured fields (ImportedService / OperationName / NewResultHandling / RequestHandling) and, when the action carries any field the structured form can't represent, set `RawBSON = a.Raw()` so the renderer emits `call web service raw ''`. Mirror legacy's supported-key set exactly; `canonicalRawBSON` makes both engines' base64 byte-identical | -| `DESCRIBE microflow` on the **modelsdk** engine emits a REST `body mapping X` line with no `from $var`, which fails to re-parse (`mismatched input … expecting FROM`) — breaks the DESCRIBE roundtrip | `restRequestHandlingFromRaw` read the export-mapping source variable from the wrong key `ParameterVariable`; the real storage key is `MappingVariableName` (same key ExportXmlAction uses), so it came back empty and the renderer dropped the grammar-mandatory `from $var` | `mdl/backend/modelsdk/microflow_read_actions.go` (`restRequestHandlingFromRaw`) | Read `ParameterVariable` from `MappingVariableName` (fallback to `ParameterVariable`). Note this makes modelsdk *more* faithful than legacy, whose `parseRequestHandling` drops the REST request body mapping entirely ("would be parsed here if needed") | -| MDL widget property `mxcli check`s clean but Studio Pro renders the default (e.g. `dataview ... (FormOrientation: Vertical)` always Horizontal) | V3 grammar generic-property branch parks the value in `w.Properties`, but the V3 builder never reads it and the writer never emits it; the widget struct has no field for it | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildXxxV3`) + `sdk/mpr/writer_widgets_*.go` (`serializeXxx`) + `sdk/pages/pages_widgets_*.go` | Add field to `pages.Xxx`; read via `w.GetStringProp` / `w.GetIntProp`; write in `serializeXxx`. If the Studio Pro UI label differs from the BSON storage name (e.g. DataView "Form Orientation" → `LabelWidth: 0/N`), confirm by diffing a Studio Pro-saved page against the reflection-data defaults | -| `ALTER ...` parse error "no viable alternative at input 'ALTER'" but `CREATE ...` works | The grammar lists ALTER variants explicitly per document type; a new doc type was added to `createStatement` but not to `alterStatement` | `mdl/grammar/MDLParser.g4` `alterStatement` block + dedicated visitor + new `Alter*Stmt` AST type + `register_stubs.go` | Add ALTER rule (mirror the closest sibling — `ALTER ODATA CLIENT` for SET-only, `ALTER PUBLISHED REST SERVICE` for SET+ADD/DROP); regenerate grammar; add `Alter*Stmt` with `Changes map[string]string`; route via `exitAlterStatement` dispatcher; register handler. Add the new AST type to `registry_test.go`'s `allKnownStatements()` | -| Pluggable widget datasource property (`optionsSourceAssociationDataSource: Module.Entity`) passes `check` + `exec` but `mx check` reports CE0642 "Property 'Entity' is required" | A `datasource`-operation property was authored by name; the engine reads the widget's `datasource:` clause (`Properties["DataSource"]`), not the named key, so the value is silently dropped (and `hasDataSource` mode-selection also misfires) | `mdl/executor/validate_widgets.go` (`datasourceTypedKeys`) — and author via the `datasource:` clause | `check` now rejects named datasource-typed props (MDL-WIDGET05); the fix for the user is `datasource: database Module.Entity`. Persisting a *named* datasource property (multi-datasource widgets) needs a Studio-Pro-verified mapping — deferred. Real-but-unmapped props get MDL-WIDGET06 warning via def.json `knownProperties` (not a false MDL-WIDGET01) | -| CE7247 "The name 'X' is a reserved word." on non-persistent entity attributes (Owner/Type/Context/Id/CreatedDate/ChangedDate/ChangedBy) — mxcli accepts the MDL silently, Studio Pro rejects the project | `ValidateEntity` early-returned for NPEs; reserved-word check was not wired into the executor | `mdl/executor/cmd_enumerations.go` (`ValidateEntity`) and `mdl/executor/cmd_entities.go` (`execCreateEntity`) | Drop the `EntityPersistent` early-return; gate only `mendixSystemAttributeNames` (MDL020) to persistent; run `mendixReservedWords` (MDL021) for all kinds; call `ValidateEntity` from `execCreateEntity` before any backend write. Issue #552 | -| `SHOW ACCESS ON PAGE` / Page section of `SHOW SECURITY MATRIX` reports "no roles" for a restricted page on the **modelsdk** engine (legacy is correct) — under-reports page access, a security-audit hazard | `pageFromGen` never read the page's allowed module roles into `Page.AllowedRoles`, so it defaulted to empty. (The gen `Page` decode is correct — it has the storage-name override `allowedRoles`↔`AllowedModuleRoles`.) The microflow/nanoflow equivalent was fixed separately | `mdl/backend/modelsdk/page.go` (`pageFromGen`) | Populate `out.AllowedRoles` from the gen `AllowedRolesQualifiedNames()` (mirrors `microflowFromGen`). Fixes both `SHOW ACCESS ON PAGE` and the matrix Page section. Issue #722 | -| `mxcli report` (and `lint`) hangs for many minutes on a large project — appears to deadlock after "Catalog ready", 130-byte banner-only output, process pinned at ~140% CPU (not blocked → not a lock deadlock) | O(N²) BSON re-decode: six lint rules loop `for mf := range ctx.Microflows()` and call `reader.GetMicroflow(mf.ID)` per microflow, but the modelsdk backend's `GetMicroflow` re-lists and re-decodes EVERY microflow unit on each call. N calls × N decodes → millions of full BSON parses. Diagnose with `kill -QUIT ` (GOTRACEBACK=all) — the running-goroutine stack names the stuck rule + `ListUnitsWithContainer` | `mdl/linter/context.go` (`FullMicroflow`, `LintReader`) + the six rules (`validation_feedback`, `conv_loop_commit`, `conv_split_caption`, `conv_error_handling`×2, `mpr008_overlapping_activities`) | Add `LintContext.FullMicroflow(id)` — loads ALL microflows once via `ListMicroflows()` and serves lookups from a per-run cache (safe: lint is read-only). Swap every `reader.GetMicroflow(mf.ID)` in a loop to `ctx.FullMicroflow(mf.ID)`. Turns O(N²) into O(N); report went from >40min hang to ~5s on 3259 microflows. Issue #720 | -| `DESCRIBE MICROFLOW` (mdl/json) times out at 300s on a high-McCabe flow, but `--format mermaid` renders in ~1s — extraction is fast, the serializer hangs | Exponential path enumeration: `duplicateOutputVariableWarnings` (run during `formatMicroflowActivities`) walked EVERY execution path, cloning the visited map at each branch (`cloneIDBoolMap`), to find output vars assigned twice on one path — O(2^branches). ~20 sequential `if/end if` diamonds already took ~10s. Diagnose with `DBGPROF=… ` CPU profile / add a pprof block to `describeMicroflow`; top-cum names the offender (not the describe traversal, which is linear) | `mdl/executor/cmd_microflows_show.go` (`duplicateOutputVariableWarnings`) | Replace the all-paths walk with **reachability**: a name is a duplicate iff two of its assignments are path-ordered (one reaches the other); exclusive-branch assignments never reach each other. Memoized `reachableFrom` is O(V·E). Loop bodies inherit names assigned by activities that reach the loop node. Went 9.5s→0.1s at 20 diamonds; 120 diamonds completes instantly. Issue #710 | -| Microflow `set $x = ` passes `mxcli check` but `mx check` reports a generic CE0117: (#17) `/` used as division (`$Dec / 2`), (#18) a decimal literal loses its fraction (`2.0` → `2`, breaks Decimal typing), or (#19) a small decimal becomes scientific notation (`0.000001` → `1e-06`, which Mendix rejects) | (#18/#19) the value is re-serialized from the AST when `shouldPreserveExpressionSource` returns false and the AST serializer is lossy for decimals (`%v` on a float64 drops `.0` and uses sci-notation). (#17) **MDL division is `div`, not `/`** — `/` is the member-access separator, so `/`-as-division is a user error that mxcli silently wrote instead of flagging. **Do NOT preserve source on `/`** — that source-freezes every legit association path (`$Order/Assoc/Name` → regressed `TestAssociationNavParsing`) | `mdl/visitor/visitor_microflow_statements.go` (`shouldPreserveExpressionSource`); `mdl/executor/validate_microflow.go` (`checkDivisionSlash`/`exprHasSlashDivision`, MDL045) | (#18/#19) preserve raw source only when the expression contains a **decimal literal** (`.` adjacent to a digit) — same philosophy as the XPath-where path. (#17) add **MDL045**: walk the expression tree for a `BinaryExpr` with operator `/` (`$Dec / 2`, `(...) / $x`) and reject with "use `div`". The variable/variable form `$Dec / $Dec2` parses as a member-access path (the `$` on the RHS is stripped), so the visitor narrowly preserves source when a `/` is immediately followed by `$` (a real association path never has `$` after `/`) and MDL045 flags it by scanning the preserved source for a `/ $` **outside string literals** (`exprHasSlashDollarDivision` / `sourceHasSlashDollarDivision`). **Round-2 gap (ledger #17 re-test): the `$a / $b` division must be caught even when EMBEDDED in a larger expression** — `$a / $b + 1`, `round($a / $b)`, `$a / $b * 100` — where it degrades to a member-path `AttributePathExpr` nested under a `BinaryExpr`/`FunctionCallExpr`, so the structural `/`-BinaryExpr walk (which only sees literal division) misses it. The earlier `exprIsSlashDollarDivision` matched only when the SourceExpr **directly** wrapped an AttributePathExpr, so embedded division slipped through silently. Fix: walk the tree for any `SourceExpr` and scan its raw source for `/ $` with a string-literal-aware scanner (skips `'path/$x'`). [verification round: the `$a / $b` form must be caught by bare `check`, not deferred to `--references`.] **Lesson: `/` is overloaded (path separator vs the division a user *wanted*) — a blanket source-preserve on `/` corrupts the common case to rescue the rare misuse; reject the misuse explicitly instead. And an operator misuse that "degrades to a path" can hide anywhere in an expression tree, not just at its root — detect it from source, walking every sub-expression.** Tests: `TestShouldPreserveExpressionSource_Decimals`, `TestValidateMicroflow_SlashDivision` (incl. embedded cases + string-literal guard); repros `mdl-examples/bug-tests/ledger-17-19-expression-serialization.mdl` (pass) + `ledger-17-slash-division.fail.mdl` (MDL045, incl. embedded). Ledger findings #17–19 (round 2) | -| `DESCRIBE` of an entity/module emits `grant ... (read (Module.Entity.Attr))` that fails to re-parse with `mismatched input '.'` — breaks the DESCRIBE roundtrip | Member emitter used the fully-qualified BY_NAME reference; grant grammar accepts a bare `IDENTIFIER` only | `mdl/executor/cmd_entities_access.go` → `resolveEntityMemberAccess` | Strip `memberName` to the last `.`-segment before appending (bare names have no dot, so it's a no-op for them). Issue #633 | -| `exec` of a pluggable widget from a bundled multi-widget `.mpk` fails "no definition for widget … (run 'mxcli widget init')" even after init (e.g. only AreaChart of Charts.mpk registers) | `ParseMPK`/`getWidgetIDFromMPK` read only `WidgetFiles[0]`; a bundled `.mpk` (Charts) holds many widgets so only the first is registered/augmented | `sdk/widgets/mpk/mpk.go` (`ParseMPKAll`/`ParseMPKWidget`, `FindMPK`) + def-gen loops in `mdl/executor/widget_defs.go` + `mdl/catalog/builder_widget_definitions.go` + `cmd/mxcli/cmd_widget.go` | Parse every widgetFile (`ParseMPKAll`); register all ids in `FindMPK`; augment the specific id (`ParseMPKWidget`); bump `WidgetDefGeneratorVersion` so existing projects regenerate. Issue #679. (Per-series datasource + chart CE0463 are separate, still open.) | -| `buttonstyle: ` passes `mxcli check` but the button renders btn-default in Studio Pro (silent at build) — typically a mis-cased value (`Primary`) or one Mendix doesn't have (`secondary`, `link`) | Visitor stored the style verbatim and the executor cast it straight to `pages.ButtonStyle`; only an empty value got a default, so any unknown string was written as-is and MxBuild degraded it | `sdk/pages/pages_widgets_action.go` (`CanonicalButtonStyle`) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (button builder) + `mdl/executor/validate_widgets.go` (`validateStaticWidget`) | Normalize case-insensitively against the metamodel `PagesButtonStyle` set (Default/Primary/Success/Warning/Danger/Info/Inverse); reject unknown values as MDL-WIDGET02 at check time and in the executor. Issue #672 | -| `style:` on a `dynamictext` crashes MxBuild with a NullReferenceException (fails at build, not check) | The generic appearance applier wrote an inline Style into the DynamicText's `Forms$Appearance`; Mendix's metamodel can't handle it | `mdl/executor/cmd_pages_builder_v3.go` (`applyWidgetAppearance`) + `mdl/executor/validate_widgets.go` (`validateStaticWidget`) | Reject an inline `style` on a dynamictext as MDL-WIDGET03 (check + executor); the workaround is to wrap it in a container and style the container. Issue #673 | -| Quoted where-clause `where '[Title=''abc'']'` (with `''` escapes) fails `mx check` CE0161 "Error(s) in XPath constraint." on every retrieve/datasource — but inline `where [Title='abc']` is clean. `mxcli exec` reports no error | The quoted string un-quoted correctly to `[Title='abc']`, but the visitor preserved the **raw** token (outer quotes + doubled `''`) as the constraint source; the builder then bracket-wrapped it → `['[Title=''abc'']']`. Affected the microflow retrieve and page datasource `expression`-form paths (the `grant … where '…'` path was fine — it uses `unquoteString` directly) | `mdl/visitor/visitor_microflow_statements.go` (`buildRetrieveWhereExpression`) + `mdl/visitor/visitor_page_v3.go` (`bracketedXPathFromExpr`, datasource WHERE) | When the whole WHERE clause is a bare string `LiteralExpr`, use its **unquoted** `.Value` as the constraint source (bracket-wrap only if it isn't already `[...]`); don't re-serialize via `xpathExprToString`/preserve-raw-source (which re-doubles the `''`). Verify both forms store identically with `mx check` = 0 errors. Issue #642 | -| `DYNAMICTEXT (Attribute: X)` (e.g. inside a LISTVIEW) is silently dropped — `describe`/BSON shows `Content: '{1}'` with no parameter binding; Studio Pro throws `System.NullReferenceException` (ClientTemplateFormPart.CollectControls) and MxBuild fails CE0720 "Place holder index 1 is greater than 0" | `buildDynamicTextV3` read `Content`/`ContentParams` but never read the `Attribute:` property, so the binding was discarded and the template defaulted to the orphaned "{1}". `mxcli check` had no orphan detection | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildDynamicTextV3`) + `mdl/executor/validate_widgets.go` (`validateStaticWidget`/`validateDynamicTextPlaceholders`) | Treat `Attribute: X` as a single auto-generated template param (route through `resolveTemplateAttributePathFull`, same as `ContentParams: [{1} = X]`; non-String attrs get `toString()`). Add **MDL-WIDGET04**: flag a dynamictext whose `Content` template references `{N}` with fewer than N bound params (counting ContentParams or a single Attribute). Issue #650 | -| A widget `show_page` whose target page is created later in the same script passes `mxcli check --references` but the executor fails with "page not found … defined later in this script" | The whole-script scriptContext used by reference validation tolerates forward refs (the page exists *somewhere*); the executor resolves page refs in statement order | `mdl/executor/validate.go` (`validateForwardPageRefs`) | An ordered pass flags a widget page ref that is neither in the project nor created earlier in the script, with the same "move the create statement earlier" hint the executor gives — keeping check consistent with execution. Issue #674 | -| Workflow constructs pass `mxcli check` but MxBuild rejects them: user task without a page (CE1834); a single-outcome user task whose one outcome has a nested activity flow (CE1876); a decision outcome that isn't a valid enum value identifier, e.g. `'Confirmed closed'` | `CreateWorkflowStmt` had **no** case in `validateWithContext` and no `ValidateWorkflow` at all — workflows received zero semantic validation | `mdl/executor/validate_workflow.go` (`ValidateWorkflow`, wired from `cmd/mxcli/cmd_check.go`) | Recursively walk `stmt.Activities`: **MDL-WF01** flag a `WorkflowUserTaskNode` with empty `Page`; **MDL-WF02** flag `len(Outcomes)==1 && len(Outcomes[0].Activities)>0`; **MDL-WF03** flag a decision/call-microflow outcome `Value` that isn't `True`/`False`/`Default` and fails the identifier regex (space/punctuation). Syntax-only (no project). The page-context-entity check (CE7412) and enum-membership form of WF03 are `--references` follow-ups. See `PROPOSAL_check_mxbuild_gap_heuristics.md` | -| A DataGrid **control-bar** button passing `$currentObject` (e.g. `Action: show_page P (Order: $currentObject)`) passes `mxcli check` but MxBuild fails CE1571 "No argument has been selected for parameter …" | A control bar sits above the grid and is not row-scoped, so `$currentObject` is unbound there; no check distinguished control-bar buttons from row-scoped (column) buttons | `mdl/executor/validate_page_button_context.go` (`ValidatePageButtonContext`, wired from `cmd/mxcli/cmd_check.go`) | **MDL-BUTTON01**: walk the widget tree tracking an `underControlBar` flag (any `controlbar`-typed ancestor, mirroring `checkLayoutGridTree`'s `underGrid`); flag any button action (and its `ThenAction` chain) whose args contain the string `$currentObject`. Row-scoped column buttons are unaffected. Syntax-only | -| `DESCRIBE` of a page/snippet emits `action: call_microflow X` / `call_nanoflow X` (button/widget actions) that fails to re-parse — breaks the DESCRIBE roundtrip | Action emitters prefixed `call_`; the page-action grammar (`actionExprV3`) accepts `MICROFLOW`/`NANOFLOW` only | `mdl/executor/cmd_pages_describe_output.go` (`extractButtonAction`) + `cmd_pages_describe_pluggable.go` (`extractCustomWidgetPropertyAction`) | Emit `microflow `/`nanoflow ` without the `call_` prefix. Issue #634 | -| `DESCRIBE` of a Java action (or `describe module … with all`) emits a body-less `create java action …;` that fails with `no viable alternative` and cascades into following statements — happens for add-on/Marketplace actions whose `.java` source isn't on disk | Body emitted only when `readJavaActionUserCode` returns non-empty, but the grammar requires `AS DOLLAR_STRING` (body is mandatory) | `mdl/executor/cmd_javaactions.go` → `describeJavaAction` | Always emit the `as $$ … $$;` block; when source can't be read, write a placeholder comment body. Issue #637 | -| Studio Pro crashes on export/upgrade/Save-As with `System.InvalidOperationException: Null value found in primitive property 'ImageDataStorage' of object of type 'Mendix.Modeler.CodeActions.MicroflowActionInfo'` after a Java action was created via MDL `exposed as … in …` (project opens fine; only re-serialization crashes) | The `MicroflowActionInfo` sub-document was written in the legacy shape: `$Type` = `JavaActions$MicroflowActionInfo`, an obsolete `Icon` key, and the four icon/image bitmaps null or absent. Mendix maps `ImageData`→ the mandatory binary `ImageDataStorage`; a null/absent value crashes `UnitWriter`. **Both engines** were wrong: legacy `sdk/mpr` emitted `ImageData: null`; the default modelsdk engine used `gen/javaactions.MicroflowActionInfo` (also legacy `$Type`, no binary fields) | struct `mdl/types/javaaction_types.go` + legacy `sdk/mpr/writer_javaactions.go` (`microflowActionInfoBSON`) + `sdk/mpr/parser_javaactions.go` (`parseMicroflowActionInfo`, shared with `parser_misc.go` JS actions) + modelsdk `mdl/backend/modelsdk/java_write.go` (`patchMicroflowActionInfo`) + reads `mdl/backend/modelsdk/javascript_read.go` | Replace `Icon string`/`ImageData string` in the struct with four `[]byte` (`IconData`/`IconDataDark`/`ImageData`/`ImageDataDark`). Emit `$Type` = `CodeActions$MicroflowActionInfo` with all four as **empty-non-null** binaries (`primitive.Binary{Subtype:0, Data:[]byte{}}`), no `Icon`. Parser reads the four binaries and **tolerates** the legacy null/`Icon` shape so corrupted units load and self-repair on rewrite. The modelsdk gen models these bitmaps as `Primitive[string]` (serialises to BSON string, can't emit `binary(0)`), so leave the gen MAI null and post-patch the top-level `MicroflowActionInfo` field via `codec.PatchBSONField` instead of editing generated code. Verify by decoding the written `.mxunit` under both engines (`grep -rlF 'MicroflowActionInfo' mprcontents/`). Issue #656 | -| `DESCRIBE` of a page emits `column Title (...)` / `column Description (...)` (datagrid columns named after a reserved keyword) that fails with `missing {IDENTIFIER, QUOTED_IDENTIFIER}` — breaks the DESCRIBE roundtrip | #619 added `mdlIdent` (quote-if-reserved) for general widget names but missed datagrid column names | `mdl/executor/cmd_pages_describe_output.go` (`column %s` header, ~line 837) | Wrap the column name: `fmt.Sprintf("column %s", mdlIdent(colName))`. Issue #638 | -| `CREATE JAVA ACTION`/`CREATE JAVASCRIPT ACTION` with an `Enumeration(Mod.Enum)`/`ENUM Mod.Enum` parameter or return type passes check+exec but `mxbuild`/Studio Pro fails: `Property 'Entity' of Java action parameter '…': The selected entity 'Mod.Enum' no longer exists` | The param/return converter mapped `ast.TypeEnumeration` to an `EntityType` (entity ref), because the parser records both a bare `Module.Name` *and* the explicit `ENUM`/`Enumeration(...)` syntax as `TypeEnumeration` with no distinguishing flag, and `EnumerationType` was never emitted/serialized | AST `mdl/ast/ast_datatype.go` (`ExplicitEnum`) + visitor `mdl/visitor/visitor_helpers.go` (`buildDataType`) + converter `mdl/executor/cmd_javaactions.go` (`astDataTypeToJavaActionParamType`/`…ReturnType`) + writers `sdk/mpr/writer_javaactions.go` (`serializeInnerType`/`serializeReturnType`) and `mdl/backend/modelsdk/java_write.go` (`codeActionInnerTypeToGen`/`codeActionReturnTypeToGen`) + reads `parser_javaactions.go` (already) / `mdl/backend/modelsdk/java_read.go` | Add `ExplicitEnum bool` to `ast.DataType`, set it in the `ENUM`/`Enumeration(...)` branch of `buildDataType` (a bare `Module.Name` stays ambiguous → entity). When `ExplicitEnum`, the converter emits `javaactions.EnumerationType{Enumeration: qn}`; both writers serialize `CodeActions$EnumerationType` (param wrapped in `BasicParameterType`) with the `Enumeration` by-name ref; modelsdk uses `genCa.NewEnumerationType().SetEnumerationQualifiedName(...)`. The legacy parser already read it; add the gen `*genCa.EnumerationType` case to the modelsdk read converters so DESCRIBE round-trips. Validate by decoding the `.mxunit` and `mx check` (build passes only after the fix). Issue #680 | -| `DESCRIBE` of a page/snippet emits widget-action microflow args as `Param = $value` that fails with `mismatched input '=' expecting ':'` — breaks the DESCRIBE roundtrip | Arg emitters used `=`, but `microflowArgV3` accepts `IDENTIFIER COLON expr` (`Param: $value`) or `VARIABLE EQUALS expr` (`$Param = …`) — not `IDENTIFIER EQUALS` | `mdl/executor/cmd_pages_describe_output.go` → `extractMicroflowParameters` / `extractNanoflowParameters` | Emit the canonical colon form: `paramName+": "+value`. Issue #640 | -| CE3637 "A data view cannot listen to the selection of Gallery 'X', because it is not available here." on a master-detail page generated from MDL | Gallery's `itemSelectionMode` pluggable-widget property was hardcoded to `clear` in the def.json; Mendix requires `toggle` for selection-listeners (DataViews bound via `DataSource: selection X`) to see the gallery's selection | `sdk/widgets/definitions/gallery.def.json` | Change the `itemSelectionMode` mapping from `value: "clear"` to `source: "ItemSelectionMode", default: "clear"` so MDL can write `gallery X (Selection: Single, ItemSelectionMode: toggle)`. The V3 engine's generic mapping path (`widget_engine.go` `resolveMapping` default case → `GetStringProp`) reads the value automatically — no grammar changes needed. Also add `ForceFullObjects: false` to `Forms$ListenTargetSource` serializer in `sdk/mpr/writer_widgets_display.go` | -| DESCRIBE drops `DataSource: selection X` for a DataView bound to a gallery/listview selection (master-detail pages) | `extractDataViewDataSource` only handled `Forms$MicroflowSource` / `Forms$NanoflowSource` / `Forms$DataViewSource` / `Forms$DatabaseSource`; `Forms$ListenTargetSource` fell through to `return nil` | `mdl/executor/cmd_pages_describe_parse.go` (`extractDataViewDataSource`) + `mdl/executor/cmd_pages_describe_output.go` (DataView case) + `mdl/executor/cmd_pages_describe.go` (rawDataSource doc-comment) | Add `case "Forms$ListenTargetSource":` returning `{Type: "selection", Reference: ds["ListenTarget"]}`; add `case "selection":` in the DataView output switch emitting `DataSource: selection ` | -| CE0463 "widget definition changed" on every pluggable widget that contains a caption/template parameter (gallery, datagrid2 captions, dynamictext with ContentParams) on Mendix 11.9 — cascades into CE3637 on master-detail pages | `serializeClientTemplateParameter` emitted `Forms$FormattingInfo` with a `TimeFormat: "HoursMinutes"` field, but the FormattingInfo reflection schema only declares CustomDateFormat / DateFormat / DecimalPrecision / EnumFormat / GroupDigits — the extra field made Studio Pro mark the embedded WidgetType as drifted | `sdk/mpr/writer_widgets.go` `serializeClientTemplateParameter` + `mdl/backend/mpr/widget_builder.go` (mirror copy of the same FormattingInfo block) | Drop the `TimeFormat` entry from both writers. Verify by diffing your BSON against a Studio Pro-saved page's FormattingInfo block — if you see keys outside the reflection schema, that's the trigger | -| Pluggable widget `Selection` BSON value is lowercase (`single` / `multi` / `none`) but Studio Pro stores PascalCase — contributes to CE0463 drift on stricter widgets and looks wrong in diffs | MDL passes the user's typed value verbatim to `SetSelection`; the builder didn't normalise | `mdl/backend/mpr/widget_builder.go` `SetSelection` | Canonicalise via `canonicalSelectionValue` helper (lowercase-keyed switch → `Single` / `Multi` / `None`); unknown values pass through | -| CE0463 on a pluggable widget whose TextTemplate property is *conditionally hidden* by an enum/boolean toggle (VideoPlayer `videoUrl`/`posterUrl` when `type=expression`; Timeline `title`/`description`/`timeIndication` when `customVisualization=true`) — engine clones the template's populated `ClientTemplate` for a property Studio Pro hides and nulls | Engine has no per-property visibility metadata; the hide rules live in the widget's compiled `editorConfig.js` (`hidePropertyIn`/`hidePropertiesIn`), not `widget.xml` | `mdl/executor/widget_defs.go` `widgetVisibilityRules` table + `mdl/backend/mpr/widget_builder.go` `ApplyPropertyVisibility` | Extract the widget's `.mpk` `*.editorConfig.js` (`unzip` + grep `hidePropertiesIn`), transcribe the rule into `widgetVisibilityRules[widgetID]` as `{propertyKey, hiddenWhen:{propertyKey, operator eq/ne/truthy/falsy, value}}`; the engine nulls hidden TextTemplate-typed props at build time. Bump `WidgetDefGeneratorVersion` so stale project `.def.json` auto-refresh. Issue #574 | -| CE0463 on a `datagrid` whose column uses `ColumnWidth: manual` + `Size: N` — Studio Pro resets the column `size` to `1` | The MDL `ColumnWidth:` keyword isn't mapped to the schema `width` enum, so `width` stays at its `autoFill` default; `size` only applies when `width=manual`, so the value is inconsistent. Regression from the Stream B keyword-path consolidation (the deleted `datagrid_builder.go` did `colPropString(col.Properties, "ColumnWidth", "autoFill")`) | `mdl/executor/widget_defs.go` `itemPropertyAliases` | Add the MDL→schema alias under `[datagrid]["columns"]`: `"width": {"ColumnWidth"}`. Bump `WidgetDefGeneratorVersion` so stale `.def.json` regenerate. General rule when a column/object-list property's MDL keyword differs from the `.mpk` schema key (not just case), add it to `itemPropertyAliases`; cross-check against the pre-B3 `datagrid_builder.go` `colProp*` calls for any other dropped mappings | -| CE0463 on an MDL-created **Combobox** (or other platform widget) at `mxcli docker build/check` time — but the SAME widget passes `mx check` on a project whose installed `.mpk` matches mxcli's embedded template (Mendix 11.6 / combobox 2.5.0). NOT the old incomplete-template bug (#112, fixed — a matching-version combobox is clean) | Widget-VERSION mismatch: mxcli emits the embedded 2.5.0-shaped PropertyTypes, but the project has a NEWER combobox (e.g. 2.8.1) that reorders/regroups properties. `augmentFromMPK` patches presence + enum values but can't restructure the baseline; `GenerateFromMPK` is less faithful still (fails even on a matching version). The designed remediation is `mx update-widgets` (docker build/check runs it before `mx check`) | `cmd/mxcli/docker/check.go` + `build.go` (`updateWidgetsPathArg`) — the update-widgets *invocation*, not the widget emission | The real trap was that `mx update-widgets ` **crashed** (`AddProjectDirAsAllowedPath` → `Path.GetDirectoryName("app.mpr")` = "" → `System.ArgumentNullException`) and some mx builds exit 0 after printing it, so the migration silently no-op'd and CE0463 survived. Pass an **absolute** path to update-widgets (always has a directory component). `mx check` is unaffected. Diagnosis: run the bundled `mx update-widgets ` yourself — if it throws ArgumentNullException on AddProjectDirAsAllowedPath, the path lacks a dir. Faithful multi-version widget emission is the larger fix (#529). Issue #112; repro `mdl-examples/bug-tests/112-combobox-enum-ce0463-widget-version.mdl` | -| **CE0463 "widget definition changed" at an `Image` widget** on Mendix 11.7+ (`mx check` without `update-widgets`) — a plain mxcli-authored Image, no custom config. `update-widgets` clears it (and on v2 destroys `mprcontents/`, so it's a data-loss trap) | NOT a version stamp, Type `$ID`s, property order, or missing properties (all ruled out empirically). The embedded `image.json` carried a **spurious default value**: a `WidgetValue.Image` pointing at `Atlas_Core.Content.Mendix` (Atlas's Mendix logo), captured when the template was extracted from a project that had it set. The installed 11.12 Image widget expects that field empty, so MxBuild flags the definition as changed | `modelsdk/widgets/templates/mendix-11.6/image.json` + `sdk/widgets/templates/mendix-11.6/image.json` (line ~63) | Clear the stale default: `"Image": "Atlas_Core.Content.Mendix"` → `"Image": ""` in both engine templates. **Diagnosis method for this whole CE0463 class**: dump the widget BSON, `mx update-widgets` on a COPY, dump again, diff the `CustomWidgets$CustomWidget` subtree **order-independently** (canonicalise key order + mask `$ID`/`TypePointer` blobs). Reordering and generic instance chrome (`LabelTemplate`, `Appearance.DesignProperties`) are cosmetic — a *passing* widget gets reordered/those-added too; the real cause is whatever value/structure survives that normalisation. Other hand-extracted templates likely hide similar stale defaults (audit with the same diff). Repro `mdl-examples/bug-tests/image-ce0463-stale-default.mdl`. DataGrid2 custom-content CE0463 (#600) is a *separate*, more complex delta — same method, own fix | -| `mxcli docker check`/`build` (or a bare `mx update-widgets`) **silently deletes `mprcontents/`** and rewrites an MPRv2 project into single-file v1 — `check` reports **0 errors** and looks successful, but the git working tree diverges from tracked files, a running `mxcli run --local` loop breaks (it watches `mprcontents/`), and Studio Pro may crash on open (`LibGit2RepositoryProvider.WriteBaseFile`). Triggered by following the CE0463 remediation on a `mxcli new` (always v2) project | The pre-check `mx update-widgets` step (run to suppress false CE0463) **performs the conversion**: it inlines every unit into the `.mpr` (`Unit.Contents` column) and deletes `mprcontents/`. The `check` itself is read-only; `update-widgets` is the mutator. docker check/build invoked it with no storage-format protection | `cmd/mxcli/docker/update_widgets.go` (`runUpdateWidgets` / `snapshotStorageFormat`) — call sites in `check.go` and `build.go` | Snapshot `.mpr` + `mprcontents/` to a temp dir before `update-widgets`, `defer restore()` after the check (restore removes the post-conversion single-file `.mpr` residue and puts the v2 tree back); MPRv1 projects need no protection. The check still runs against the widget-normalized model, so CE0463 stays suppressed — only the on-disk format is preserved. **Never tell an agent to run bare `mx update-widgets` on a v2 project** — the synced skills (`create-page.md`, `custom-widgets.md`, `migrate-design-prototype.md`, `download-marketplace-content.md`) + dev `debug-bson.md` route to `mxcli docker check`/`build` (v2-safe) instead. **Fix the operation, not the call site**: PR #764 wrapped the invocation inside `Check` only, and `Build` had a second bare copy — so `docker build`/`run`/`reload` kept converting projects for another 40 issues, until #808. The snapshot now lives in `runUpdateWidgets`, which is the only place that may exec `update-widgets`; grep for `"update-widgets"` should return exactly one hit. When a mutating external step is guarded, put the guard in a function that also *performs* the step, so a new caller cannot get it wrong. Issues #763 / PR #764, #808 | -| Nightly `mx check` reports `CE0117 "Error(s) in expression." at Log message activity 'Log message (warning)'` on Mendix 10.24.19+ but not 10.24.16 or 11.x | Mendix 10.24.19 tightened expression validation: `toString()` is now a type error (toString expects a non-string input). An example called `toString($OrderNumber)` where `$OrderNumber` was already a string parameter | The offending `log warning ... with ({1} = toString($stringVar))` — find via `~/.mxcli/mxbuild/{ver}/modeler/mx check`, then bisect with `drop microflow ...` until CE0117 disappears | Remove the redundant `toString()` wrapper around already-string values. Only wrap non-string values (integers, decimals, dates, enums) in `toString()`. The Mendix 11.x parser is more lenient and lets this slide, but 10.24.19+ rejects it | -| A page-level property can't be set — `ALTER PAGE X { SET PopupWidth = 800; }` (or any page-level prop other than Title/Url) fails with `unsupported page-level property: …` | The page-level SET handler only special-cased a couple of properties; everything else fell through to the default error | `mdl/backend/pagemutator/mutator.go` → `applyPageLevelSetMut` (shared by both engines) | Add a `case` writing the field at the top level of the Forms$Page doc via `dSetOrAppend` with the on-disk BSON type (int64 for PopupWidth/PopupHeight, bool for PopupResizable — verify against a Studio Pro page with `mxcli bson dump --format bson`). Page-level prop names are **case-sensitive**. For DESCRIBE roundtrip, emit the values back in the CREATE PAGE header. CREATE-time support: add a generic `IDENTIFIER COLON propertyValueV3` to `pageHeaderPropertyV3` (regen grammar), recognize the keys in `parsePageHeaderV3` (`applyGenericPageHeaderProp`, error on unknown), carry `*int`/`*bool` on `CreatePageStmtV3`, default to 600/600/false in `buildPageV3`, and have both writers honour `page.Popup*` (legacy `sdk/mpr/writer_pages.go` int64; codec `mdl/backend/modelsdk/page_write.go` int32 via gen — tolerated by mx check). The MCP backend has its **own** `mcpPageMutator` (pg content tree, not raw BSON) — page-level SET there reaches `SetWidgetProperty("")`; map it or reject honestly (it rejects, pending a `pg_read_page` probe of the pop-up keys). Issue #661 | -| `PopupWidth: 0` / `PopupHeight: 0` rejected ("must be a positive number") on CREATE or ALTER PAGE; user can't make an auto-size pop-up | 0 is actually Studio Pro's **default** for pop-up dimensions (auto-size) — verified live on 11.12: a pg-created PopupLayout page stores 0/0 and `mx check` = 0 errors. Two validators rejected ≤0, and both writers coerced ≤0→600, so even an allowed 0 became 600 | `mdl/visitor/visitor_page_v3.go` (`popupDimensionValue`) + `mdl/backend/pagemutator/mutator.go` (`coercePopupDimension`) + `mdl/executor/cmd_pages_builder_v3.go` (builder default) + `sdk/mpr/writer_pages.go` & `mdl/backend/modelsdk/page_write.go` (`popupDimension`) + `mdl/executor/cmd_pages_describe.go` | Relax both validators to reject only **negative**; default the builder to **0** (not 600, matching Studio Pro); drop the `≤0→600` coercion in both writers (clamp only negatives to 0); have DESCRIBE suppress only the real default 0 (emit an explicit 600). No `*int` needed — 0 is a valid stored value. Bug-test `mdl-examples/bug-tests/713-popup-zero-dimensions.mdl`. Issue #713 | -| `CREATE EXTERNAL ENTITIES FROM Module.Service` imports external entities as plain **PERSISTED** entities and NPEs **without the "from Service" link** (regression on the default modelsdk engine; legacy `--engine legacy` was correct) | The modelsdk write adapters dropped all external-entity serialization: `entityToGen` only handled `DomainModels$OqlViewEntitySource`, `attributeToGen` always emitted `StoredValue`, and `assocToGen` forced `Source` null. So `e.Source = "Rest$ODataRemoteEntitySource"` (+ RemoteServiceName/EntitySet/KeyParts) was ignored and the entity fell to the plain NoGeneralization path | `mdl/backend/modelsdk/domainmodel_write.go` (`entityToGen`, `attributeToGen`, `assocToGen`) — the executor (`applyExternalEntityFields` in `mdl/executor/cmd_contract.go`) already stamps the right `domainmodel` fields; only the write adapter was lossy. The legacy serializer `sdk/mpr/writer_domainmodel.go` is the parity reference | Port the legacy logic into the codec adapters using the gen `Rest$OData*` types (all present in `modelsdk/gen/rest`): `externalEntitySourceToGen` builds `Rest$ODataRemoteEntitySource`/`…EntityTypeSource`/`…PrimitiveCollectionEntitySource` (+ `ODataKey`/`ODataKeyPart`, with fresh IDs since `assignEntityIDs` only stamps the top source); `attributeToGen` takes an `isExternal` flag and emits `Rest$ODataMappedValue` / `…MappedPrimitiveCollectionValue`; `externalAssociationSourceToGen` emits `Rest$ODataRemoteAssociationSource` / `…PrimitiveCollectionAssociationSource` (the `DomainModels$Association` NullFields:["Source"] default still nulls Source for plain associations because SetSource only emits when set). Tests in `external_entity_write_test.go`. Issue #718 | -| `describe page` shows a widget's default placeholder text (e.g. the Dutch `'Tekst'` for a DynamicText) instead of the configured content; round-tripping describe→create then overwrites the real caption | Text extraction was language-blind: `extractTextContent`/`extractTextCaption`/`extractTextFromTemplate` returned the **first** `Texts$Text` `Items[]` entry, and the page title hardcoded `GetTranslation("en_US")`. In a multi-language project the first Items entry is often a non-default-language placeholder, so the wrong translation is shown. (The MDL surface is single-language regardless — this is display-only; note the real text isn't lost on disk, only mis-displayed) | `mdl/executor/describe_language.go` (new) + the three extractors in `cmd_pages_describe_output.go`/`cmd_pages_describe_parse.go` + title in `cmd_pages_describe.go` | Select the translation by **project default language → en_US → first non-empty** via `selectTranslationText` / `pickTextTranslation`; get the default language from `ctx.Backend.GetProjectSettings().Language.DefaultLanguageCode`, cached on `executorCache` and pre-warmed in `preWarmCache` (race-free for parallel describe). No MDL bug-test possible (MDL can't author >1 translation) — covered by unit tests in `describe_language_test.go`. Issue #702 | -| "Class is not a Page property" — can't set a CSS class/style on a page via `CREATE PAGE (Class: '…')` (grammar rejects it) or ALTER PAGE | A page's Class/Style live on its `Forms$Appearance` sub-doc, but nothing wired it to MDL: `pages.Page` had no Class/Style field, the writers hardcoded the appearance empty, and `Class`/`Style` are reserved lexer tokens so the header's generic `IDENTIFIER` branch never matched them | grammar `mdl/grammar/domains/MDLPage.g4` (`pageHeaderPropertyV3`) + `pages.Page` + AST `CreatePageStmtV3` + visitor `visitor_page_v3.go` + builder `cmd_pages_builder_v3.go` + writers `sdk/mpr/writer_pages.go` & `mdl/backend/modelsdk/page_write.go` + describe `cmd_pages_describe.go` + ALTER `mdl/backend/pagemutator/mutator.go` (`applyPageLevelSetMut`) | Add explicit `CLASS COLON STRING_LITERAL` / `STYLE COLON STRING_LITERAL` alternatives to `pageHeaderPropertyV3` (regen grammar — keyword tokens don't match `IDENTIFIER`); add `Class`/`Style` to `pages.Page` + AST + visitor header handling + builder; legacy `serializePage` and modelsdk `newAppearance(page.Class, page.Style, …)` honor them; describe reads `rawData["Appearance"].Class/Style` into the CREATE PAGE header; ALTER page-level SET writes the `Appearance` sub-doc (create if absent). Same pattern as PopupWidth #661/#713. Bug-test `mdl-examples/bug-tests/714-page-class.mdl`. Issue #714 | -| `CreatePage: ListView source *pages.DatabaseSource not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy` — a native `listview` with a `database`/`database from … where …` datasource fails on the default engine | `listViewSourceToGen` only handled `*pages.MicroflowSource`; database (and context/listen/association) sources fell through to the "not supported" error. DataView, DataGrid2, and Gallery database sources already worked — only native ListView was missing | `mdl/backend/modelsdk/widget_write.go` (`listViewSourceToGen`); mirror the `customWidgetDataSourceToGen` database branch and the legacy `serializeListViewDataSource` | Add a `*pages.DatabaseSource` case building `genPg.NewListViewXPathSource()` with EntityRef (`DirectEntityRef`), XPathConstraint, a `NewGridSortBar()` (GridSortItem per `d.Sorting`, note gen uses **SortBar** not the legacy `Sort`/`ListViewSort`), and a `NewListViewSearch()` — the codec already has all types. Verify with `mx check` = 0 errors. Tests in `listview_source_write_test.go` | -| `Visible: [Attr != '']` / `Editable: […]` on a widget (CONTAINER, textbox, …) → CE0117 "Error(s) in expression." in Studio Pro on the legacy engine, and silently *ignored* on the modelsdk engine | Two bugs (NOT BSON structure): (1) the expression was emitted with a bare attribute reference (`Name != ''`) — a Mendix client expression must root attributes in the widget data context (`$currentObject/Name != ''`); (2) the modelsdk codec hardcoded `ConditionalVisibilitySettings` to null, dropping the expression entirely | `mdl/visitor/visitor_page_v3.go` → `buildConditionalExpression`/`conditionalExprToString`; `mdl/backend/modelsdk/widget_write.go` → `applyWidgetBase` + TypeDefaults | (1) For `VISIBLE`/`EDITABLE` xpath constraints, build the xpath AST and serialize via `conditionalExprToString`, which prefixes bare `IdentifierExpr` / non-variable-rooted `XPathPathExpr` with `$currentObject/` (leaves `$…`-rooted paths, literals, functions, enum qualified-names untouched). Do **not** apply this to datasource `where` clauses (those are real XPath). (2) In `applyWidgetBase`, type-assert `SetConditionalVisibilitySettings`/`SetConditionalEditabilitySettings` and emit a settings element when the model has one; register `Forms$ConditionalVisibilitySettings`/`…EditabilitySettings` TypeDefaults (`NullFields: Attribute,SourceVariable`; `MandatoryLists: Conditions[,ModuleRoles]`) so the null-when-unset slots stay null (the encoder only fills NullFields when not already emitted). **ALTER PAGE** had the same gap (`SET Visible = [expr]` parsed as a `propertyValueV3` array and silently no-op'd): add `VISIBLE/EDITABLE EQUALS xpathConstraint` alternatives to `alterPageAssignment` (regen grammar), route to `VisibleIf`/`EditableIf` in `buildAlterPageAssignment` (reusing `buildConditionalExpression`), and add `VisibleIf`/`EditableIf` cases to `setRawWidgetPropertyMut` (`mdl/backend/pagemutator/mutator.go`) that build the settings node via `setWidgetConditionalSettingMut` (rejecting editability on non-input widgets). The shared mutator covers both engines. Verify both engines with `mx check` = 0 errors. Issue #627 | -| `visible: [$currentObject/Status = Mod.Enum.Value]` stored as `… = 'Value'` (string) → MxBuild CE0117 "Error(s) in expression" (v0.13.0 regression; both engines) | `conditionalExprToString` (added with #627) sent a qualified-enum `QualifiedNameExpr` to its `default` case → `xpathExprToString`, which converts a 3-part enum name to a string literal. Correct for an XPath *datasource* constraint (DB level), WRONG for a *client* visibility/editability expression (compares to the qualified enum value) | `mdl/visitor/visitor_page_v3.go` → `conditionalExprToString` | Add an explicit `*ast.QualifiedNameExpr` case returning `e.QualifiedName.String()` (the qualified literal) before the `default`. Leaves the datasource `where` path (`buildXPathString`/`xpathExprToString`) untouched, so DB-level enums still stringify to `'Value'`. Applies to CREATE and ALTER (both use `conditionalExprToString`). Verify the datasource enum still stores `'Value'` and the visibility enum stays qualified; `mx check` = 0 | -| CE0488 "No entity configured for the data source of this data view" on a context (page-parameter-bound) data view authored over **MCP** — DESCRIBE looks correct (`dataview dvX (DataSource: $Param)`), but the model has no entity on the source. DataGrids with database sources are fine | `mapDataViewSource`'s `*pages.DataViewSource` case used a mutually-exclusive `switch`: when `ParameterName != ""` it wrote only `sourceVariable` and never `entityRef`. Studio Pro/pg write **both** entityRef AND sourceVariable for a context source (see `testdata/pg-page-contact-newedit.json`) | `mdl/backend/mcp/page_widgets.go` → `mapDataViewSource` | Drop the exclusive switch: set `entityRef` (DomainModels$DirectEntityRef) whenever `EntityName != ""` AND `sourceVariable` (Pages$PageVariable) whenever `ParameterName != ""`. The executor already resolves the parameter's entity into `EntityName` (`cmd_pages_builder_v3.go` parameter case), so both are available. MCP-only — the MPR engines were never affected | -| Mendix can't resolve the microflow named in `CREATE ODATA CLIENT (ConfigurationMicroflow: microflow X.Y)` / `ErrorHandlingMicroflow:` — error names the literal string `"MICROFLOW X.Y"` as the missing microflow | Case-mismatched prefix strip: visitor emits uppercase `"MICROFLOW "` from `odataValueText`, but `extractMicroflowRef` only trimmed lowercase `"microflow "`, so the keyword survived into BSON | `mdl/executor/cmd_odata.go` → `extractMicroflowRef` | Use a case-insensitive strip: `if strings.EqualFold(ref[:10], "microflow ") { return ref[10:] }`. Whenever a value goes from a visitor that emits a keyword-prefixed form to an executor that strips it, the strip must match the case the visitor produces — grep visitor files for `"MICROFLOW " +`/`"ENTITY " +`/etc. when adding a new property. Issue #573 | -| `describe`/catalog reports a numeric BSON field (Length, MinOccurs, MaxOccurs, MaxLength, FractionDigits, TotalDigits, Interval, NumberOfPagesToClose, …) as `0` / `unlimited` even though Studio Pro shows a real value | BSON numeric width mismatch — Studio Pro writes the field as `int64`, but the parser asserted `raw["X"].(int32)` so the type assertion failed silently and the field defaulted to its zero value | `sdk/mpr/parser_*.go` — grep for the field name; the fix point is the narrow assertion | Replace narrow type assertions on BSON numeric fields with the existing `extractInt(raw["X"])` helper (`sdk/mpr/parser.go`). It handles int32/int64/int/float64. When a non-zero default must survive a missing field, gate with `if _, ok := raw["X"]; ok { … = extractInt(...) }`. Sweep `grep -n '\.(int32)' sdk/mpr/parser_*.go` and ignore matches whose comment says "marker" (BSON-array-prefix probes are intentional). Issues #583, #585 | -| Studio Pro shows a dropdown / property as its default value even though MDL set it explicitly (e.g. CREATE ODATA CLIENT with `ConfigurationMicroflow:` set, but the "Configuration source" dropdown reads "Constants only") | The BSON key mxcli writes isn't what Studio Pro reads — either the key name is wrong, or multiple dropdown options actually share a single field discriminated by something other than the key name (return type of a referenced microflow, sibling property, etc.) | The `serializeXxx` function in `sdk/mpr/writer_*.go` for the affected document type | (1) Ask the user to duplicate the offending object in Studio Pro and **explicitly pick each dropdown option** on the duplicate(s). An unconfigured duplicate just looks like "Constants only" and tells you nothing about which field the option uses. (2) Re-dump the duplicates from `mprcontents/**/*.mxunit` **after every Studio Pro change** — cached `/tmp/svc-*.json` files go stale the instant the user edits the project (see [[feedback-refresh-bson-dumps]]). (3) Diff against mxcli's output to find the renamed key. (4) Don't assume one-state-per-key. The OData "Configuration microflow" / "Headers microflow" case stores BOTH options in the single `ConfigurationMicroflow` BSON field — Studio Pro picks the dropdown label from the referenced microflow's return type. When a discriminator like that exists, have both MDL keywords write to the same model field. Issues #573, #587 and 2026-05-27 unify-config-microflow fix | -| `DESCRIBE` of a document type omits a property the user set in the CREATE (e.g. enum-level `/** doc */` vanishes after a roundtrip; `CREATE OR REPLACE` silently runs as plain CREATE for some types) — even though the AST struct has the field, the model has the field, the writer serialises it, and DESCRIBE prints it | Visitor never copied the parsed value into the AST struct — every layer below the visitor is wired but the visitor's `ExitCreateXxxStatement` is missing the assignment | `mdl/visitor/visitor_.go` → `ExitCreateXxxStatement` | Diff the visitor against a known-good sibling (e.g. enumeration vs constant). Standard wiring: `stmt.Documentation = findDocCommentText(ctx)` for doc-comments; `if createStmt.OR() != nil && (createStmt.MODIFY() != nil \|\| createStmt.REPLACE() != nil) { stmt.CreateOrModify = true }` for OR MODIFY/REPLACE. When adding a new CREATE statement, grep `mdl/visitor/visitor_constant.go` and copy these two blocks verbatim. Issue #393 | -| `create [or modify] association ... to System.X` passes `mxcli check --references` and `mxcli diff` but fails at `mxcli exec` with `child entity not found: System.X` | Two divergent entity resolvers: the write path's `findEntity` resolved the owning module via `h.FindModuleID(dm.ID)`, but the virtual System domain model is not a real unit, so the hierarchy walk yielded an empty module name and System entities never matched. The validation path (`buildEntityQualifiedNames`) keyed on `dm.ContainerID` and worked, hence the check-passes/exec-fails split | `mdl/executor/oql_type_inference.go` → `findEntity` | Resolve the module from `dm.ContainerID` (the module ID `BuildSystemDomainModel` sets), not by walking up from the DM's own unit ID. When a symptom is "passes check/diff but fails exec," suspect two resolvers and make the write-path one match the validation-path one; add an `exec`-level test, not just a `check` test. Issue #610 | -| `ALTER STYLING ON PAGE/SNIPPET ... SET ...` fails with `unsupported container type: PAGE` (and `DESCRIBE STYLING` silently shows "No widgets found"); even past that, design-property writes never reached builder-created pages | Two layered bugs: (1) the visitor emits uppercase `ContainerType` `"PAGE"`/`"SNIPPET"` but `execAlterStyling`/`execDescribeStyling` compared lowercase, so it fell through to the unsupported-container error; (2) ALTER STYLING used the reflection walker `walkPageWidgets` (legacy `ListPages`/`UpdatePage`), which can't locate widgets in MDL-builder pages and violates the mutator-pattern rule | `mdl/executor/cmd_styling.go` (`execAlterStyling`, `execDescribeStyling`) + `mdl/backend/mpr/page_mutator.go` | Normalise container type with `strings.ToLower`. Route ALTER STYLING through `ctx.Backend.OpenPageForMutation(unitID)` like ALTER PAGE; check `mutator.FindWidget`. Add `SetDesignProperty`/`RemoveDesignProperty`/`ClearDesignProperties` to the `PageMutator` interface, writing the widget's `Appearance.DesignProperties` BSON array (`Forms$DesignPropertyValue` → `Toggle`/`Option`/`Custom` value), preserving an existing custom kind on option updates. When an ALTER uses a container-type discriminator, mirror the casing fix already done for ALTER PAGE (#402). Issue #631 | -| `declare $x list of T = empty;` (or any list-typed `declare`) passes `mxcli check` but Studio Pro rejects with CE0053 ("type not allowed") + CE0038 ("value required") | `declare` maps to a Create Variable activity, which Mendix forbids from producing a list — but the validator only flagged an empty list *used as a loop source* (MDL002), never the declaration itself | `mdl/executor/validate_microflow.go` → `walkBody` `*ast.DeclareStmt` case | Emit `MDL040` (SeverityError) for any `stmt.Type.Kind == ast.TypeListOf`, regardless of initializer. Lists must come from a microflow parameter, a `retrieve`, or `$x = create list of T;`. Also fix the synced skills that present declare-list as valid (`write-microflows.md`, `cheatsheet-variables.md`, `check-syntax.md`, `patterns-*`). Issue #607 | -| `DESCRIBE PAGE` emits a widget whose name is a reserved keyword (`container List`, `dynamictext Template`) and the output fails `mxcli check` (`mismatched input 'List' expecting IDENTIFIER`); quoting it by hand (`container "List"`) also failed | The widget-name position `widgetV3` accepted only a bare `IDENTIFIER`, so neither the keyword nor a `QUOTED_IDENTIFIER` parsed; and DESCRIBE emitted the name unquoted. Note this is *not* the general reserved-word problem — qualified names / params / attributes already go through `identifierOrKeyword` (a 552-entry keyword allowlist). The gap is the ~103 strict-`IDENTIFIER` positions, of which widget names are the highest-impact | grammar `mdl/grammar/domains/MDLPage.g4` (`widgetV3`) + `mdl/visitor/visitor_page_v3.go` (`buildWidgetV3`) + `mdl/executor/cmd_pages_describe_output.go` | Widen the name position to `(IDENTIFIER \| QUOTED_IDENTIFIER)`, `make grammar`, and `unquoteIdentifier` it in the visitor. On output, quote via `executor.mdlIdent` (which lexes the name and quotes only when it does *not* lex as a bare `IDENTIFIER` — no hardcoded keyword list, no false positives like a widget named "Dot"). For other strict-`IDENTIFIER` name positions apply the same pattern: `pageParameter`/`snippetParameter` bare names were widened the same way (issue #114 — note the SHOW_PAGE colon arg it reported already worked via `identifierOrKeyword`, and DESCRIBE emits params `$`-prefixed so output was already safe; the fix only closed the bare-declaration gap). Security `read(...)`/`write(...)` member lists and the validation-rule `attributeReference` were widened to `(IDENTIFIER \| QUOTED_IDENTIFIER)` via dedicated sub-rules (`entityMemberName`, `attributeRefSegment`); the grant DESCRIBE emitter quotes members through `mdlIdent` for a clean roundtrip (issue #675). Issues #619, #114, #675 | -| `DESCRIBE` of a navigationlist item (`item List`), a fragment widget (`container List`), or a workflow `user task Value` / `jump to Value` emits the name BARE and the output fails `mxcli check` (`mismatched input … expecting IDENTIFIER`) — same root cause as #619 but four further emitter positions the widget-name slice didn't reach | These four output sites interpolated the name directly instead of via `mdlIdent`. Unlike the strict-`IDENTIFIER` cases, the receiving grammar rules already accept `(IDENTIFIER \| QUOTED_IDENTIFIER)` (`widgetV3` ITEM, `widgetV3`, `workflowUserTaskStmt`, `workflowJumpToStmt`), so **no grammar change is needed** — wrapping the emitted name is sufficient | `mdl/executor/cmd_pages_describe_output.go` (`item %s`, ~line 633), `mdl/executor/cmd_fragments.go` (`outputASTWidgetMDL`, ~line 169), `mdl/executor/cmd_workflows.go` (`jump to %s` ~line 321 + `formatUserTask` ~line 418) | Wrap each name in `executor.mdlIdent(...)`. Regression tests in `mdl/executor/issue619_emitter_quoting_test.go`; roundtrip fixture `mdl-examples/bug-tests/619-quoted-reserved-emitter-names.mdl`. The *other* class of #619 gaps (page-domain `microflowArgV3` show_page/microflow arg param names, `dataSourceExprV3` SELECTION ref, `sortSpec` list-sort attr) also needed a grammar widen — now done: each rule widened to `(IDENTIFIER \| QUOTED_IDENTIFIER)` (`make grammar`), the visitor unquotes (`buildMicroflowArgV3`, `buildDataSourceV3` SELECTION, both `sortSpec` consumers), and the emitters quote via `mdlIdent` (`extractMicroflowParameters`/`extractNanoflowParameters`/`extractPageParameters`, `DataSource: selection`, list `sort(...)`, plus the fragment `selection` builder). Tests: `mdl/visitor/visitor_page_quoted_test.go` (`TestQuotedReservedSelectionAndArgNames`), `mdl/visitor/visitor_microflow_sort_quoted_test.go`, `cmd_microflows_format_listop_test.go`; fixture `mdl-examples/bug-tests/619-quoted-reserved-grammar-positions.mdl`. NOTE a reserved attribute like `Date` in a list `sort(...)` was *already* emitted bare and broken before this — the fix quotes it. Issue #619 | -| Under the default modelsdk engine a SHOW/DESCRIBE errors `… not implemented yet — rerun with MXCLI_ENGINE=legacy`, OR the read "works" but DESCRIBE drops nested config (return type, parameters, exposed-as info) into a half-shell that's fine under `--engine legacy` | The modelsdk `Backend` never overrode the read method (falls through to the generated `unimplemented` embed), OR a gen-accessor converter read children under the codegen property names while real Studio-Pro BSON stores them under the original storage names — the gen codec decodes those keys into empty lists/parts | `mdl/backend/modelsdk/*_read.go` (add the override) + `mdl/backend/modelsdk/unimplemented_gen.go` (confirm the stub) | First confirm the gap: `comm -23 <(grep -hoP 'errUnimplemented\("\K[^"]+' mdl/backend/modelsdk/unimplemented_gen.go \| sort -u) <(grep -rhoP 'func \([a-z]* \*Backend\) \K[A-Za-z0-9_]+' mdl/backend/modelsdk/*.go \| grep -v _test \| sort -u)`. Then **dump the real raw keys before writing the converter** (a gen-accessor port silently half-shells): iterate `b.ListRawUnitsByType(typePrefix)` + `b.GetRawUnit(id)` and read the fidelity-sensitive children from the empirical keys, not the gen accessors. JS actions store `JavaReturnType`/`Parameters`/`TypeParameters`/`MicroflowActionInfo` (gen wants `ActionReturnType`/`ActionParameters`/`ActionTypeParameters`/`ModelerActionInfo`) — see `javascript_read.go`. Validate with describe-parity vs `--engine legacy` (modelsdk may be *more* correct, e.g. recovers `Enum X` where legacy drops to `Object`). Issue 7 | -| `DESCRIBE PAGE` / `DESCRIBE STYLING` silently drops a **compound** (nested) design property — e.g. Atlas `Spacing` → margin-top/bottom — even though it was written to BSON correctly and shows in Studio Pro; only flat toggle/option props survive the read-back | The describe-side parser `extractDesignProperties` had no `Forms$CompoundDesignPropertyValue` case (dropped it entirely), and the emitters had no `compound` branch — the write half of #668 was done but the read/roundtrip half wasn't | `mdl/executor/cmd_pages_describe_parse.go` (`extractDesignProperties` → `parseDesignProperty`) + `cmd_pages_describe_output.go` (`formatDesignPropertiesMDL` → `joinDesignPropertyEntries`) + `cmd_styling.go` (DESCRIBE STYLING emitter) | Parse `Forms$CompoundDesignPropertyValue` by recursing over its `Properties` list (each child is again a `Forms$DesignPropertyValue`) into `rawDesignProp.Nested`; emit recursively as `'Key': ['sub': 'v', …]`. Share one formatter (`joinDesignPropertyEntries`) across both describe paths so toggle/option/compound render identically. Verify with a write→`describe`→re-`check` roundtrip (the describe output must re-parse). When a feature writes a construct, always confirm the **describe read-back** too — write-only completeness is the recurring half-shell trap. Issue #668 | -| `mxcli -p "C:\\path\\App.mpr"` (Windows backslash path) fails with `unable to open database file (14)` and the echoed path is mangled (`C:\temp` → `C:emp`, backslashes dropped); forward slashes (`-p C:/path/App.mpr`) work | The CLI fabricates `CONNECT LOCAL ''` MDL from the flag value and re-parses it, so the lexer's `unquoteString` interprets `\t`/`\n`/`\r` and escape backslashes in the path. The positional `.mdl` arg is a raw path, so it's unaffected | every `cmd/mxcli/*.go` `fmt.Sprintf("CONNECT LOCAL '%s'...", projectPath)` site + `mdl/visitor/visitor_helpers.go` | Never interpolate a raw filesystem path into MDL source. Wrap it in `visitor.QuoteString(projectPath)` (escapes `\`→`\\`, `'`→`''` — the exact inverse of `unquoteString`) at every CONNECT-string site. Round-trip test in `mdl/visitor/visitor_connection_test.go`. Issue #644 | -| `ALTER ENTITY ... ADD ATTRIBUTE` (or any ALTER) succeeds and `DESCRIBE ENTITY` is byte-identical before/after, but `mx check` then reports CE1613 "attribute … no longer exists" — often on a *different* entity's inherited system member (`createdDate`) referenced by grids/sort-bars in another module | The ALTER target is rebuilt from the semantic `domainmodel.Entity` (`entityToGen` → fresh `NewEntity`, `raw==nil`), so the codec's `EmitGUID` default writes `GUID = $ID`, discarding the entity's real on-disk GUID. The GUID is the stable cross-reference identity inheriting entities/pages resolve members through, so changing it dangles those refs. The forgiving reader re-derives DESCRIBE from flags and never shows GUID, so mxcli can't see its own damage. (Legacy `sdk/mpr` had the broader form: it re-serializes the *whole unit* and forces `GUID = $ID` on every sibling too) | `mdl/backend/modelsdk/domainmodel_alter.go` → `UpdateEntity` | Transplant the original element's raw bytes onto the rebuilt target (`ge.SetRaw(orig.Raw())`) so the codec treats it as an EXISTING element: dirty properties re-encode from `ge`, unmodeled fields (GUID, Capabilities, …) pass through verbatim. Siblings already survive via the list-rebuild raw passthrough. When a write "succeeds" but `DESCRIBE` is suspiciously byte-identical, suspect an identity (`$ID`/`GUID`) the reader doesn't surface — assert on raw BSON, not DESCRIBE. Issue #657 | -| `mx check` reports CE6621 "Attribute 'X' has max length '200' which is not the same as max length in the OData service which is 'unlimited'" on every unlimited-length string attribute of a `create external entities from …` import — **only under the default modelsdk engine**; `--engine legacy` is clean | The codec's encoder only emits *dirty* properties for a new element. `attributeTypeToGen` skipped `SetLength` when `Length == 0` (unlimited), so the `Length` property stayed non-dirty and the field was omitted from the BSON entirely. Studio Pro then applies its own UI default of 200, which contradicts the OData service's "unlimited" type. Legacy's `writer_domainmodel.go` always writes `Length: t.Length` (including 0), so it never regressed | `mdl/backend/modelsdk/domainmodel_write.go` → `attributeTypeToGen` (`*domainmodel.StringAttributeType` case) | Always call `g.SetLength(int32(at.Length))` — drop the `if at.Length > 0` guard — so `Length: 0` is emitted explicitly, matching the legacy serializer. General rule for the codec write path: any field whose *zero value is meaningful* (0 = unlimited here) must be Set unconditionally, because an omitted field falls back to Studio Pro's own default, not to zero. Assert on the serialized `NewType` map (not DESCRIBE) — the reader treats a missing Length as unlimited so it hides the damage. Test `TestEntityToGen_ODataRemoteEntitySource` in `external_entity_write_test.go`. Issue #718 | -| `mxcli oql` against a docker-deployed app returns **0 rows** for tables that clearly have data (false negative — tester concluded "saving is broken") | NOT a database-config problem (that's a red herring — proven: aligning the runtime to Postgres with data still returned 0). The OQL preview servlet (`/dev/preview_execute_oql`) is registered by `com.mendix.basis.livepreview.LivePreviewRegistry` → `AppContainer.addDevelopmentServlet`, which **only mounts the servlet when the JVM system property `mendix.running.locally.by.studiopro=true` is set** (Studio Pro sets it; a deployed `bin/start` PAD doesn't). Without it the endpoint returns HTTP 200 `{"result":-5,"message":"Action not found"}`, which mxcli silently parsed as empty data. (Found by decompiling `com.mendix.mxruntime.jar` / `com.mendix.appcontainer.jar`.) | `cmd/mxcli/docker/templates/docker-compose.yml` (the `command`) + `cmd/mxcli/docker/oql.go` (`oqlDevError`) | Add `-J -Dmendix.running.locally.by.studiopro=true` to the start command (each JVM opt needs its own `-J`), next to the existing `-Dmendix.live-preview=enabled` — this mounts the dev servlets and `mxcli oql` returns live data (verified end-to-end; DTAP mode is NOT required, DB config is irrelevant — the preview runs in-process against the runtime DataStore). Separately, make `oqlDevError` surface `{"result":<0,"message":…}` (not just `{"error":…}`) so the failure isn't swallowed as 0 rows. **Related startup bug found:** the generated `etc/Default` inherits the project's ports, which are `0` when unset → the runtime rejects port 0 and won't start; `patchRuntimePorts` (patch.go) forces `runtime.http { port = 8080 }` / `admin { port = 8090 }`. Verified with a full docker build/run/seed cycle | -| `check --references` on a view entity reports `could not parse select clause from OQL query` for a valid OQL query (any `select … from …`), AND MDL031 OQL type-mismatch checks never fire (misses real errors) | `extractSelectClause` searched an **uppercased** query (`upperOql`) for the **lowercase** needle `"select"`, and compared `strings.ToUpper(oql[i:i+4])` against lowercase `"from"`/`"union"` — case mismatches that never match, so it returned `""` for *every* query. That single `""` both triggers the false-positive warning (`inferOQLTypes`) and short-circuits `ValidateOQLTypes` (early `return` on empty select clause) so no type checking runs | `mdl/executor/oql_type_inference.go` → `extractSelectClause` | Compare case-consistently: `strings.Index(upperOql, "SELECT")` and slice keyword comparisons from `upperOql` (`word := upperOql[i:i+4]; word == "FROM"`). The static inferrer stays conservative (division / attribute refs → `TypeUnknown` → skipped), so re-enabling it doesn't over-fire on case/division aggregates. Tests: `TestExtractSelectClause`, `TestValidateOQLTypesNoFalsePositive` in `oql_type_inference_test.go`. Bug 9b | -| `mxcli widget docs` omits all but the first widget of a bundled multi-widget `.mpk` (e.g. Charts.mpk emits only `areachart.md`; ColumnChart/BarChart/PieChart/LineChart/BubbleChart missing) | Same class as #679 in an unfixed code path: `RegenerateWidgetDocs` used `mpk.ParseMPK` (returns `WidgetFiles[0]` only), while the def-generation loop (`RefreshWidgetDefinitions`) already used `ParseMPKAll`. Charts.mpk bundles 10 widgetFiles | `mdl/executor/widget_defs.go` → `RegenerateWidgetDocs` | Swap the per-`.mpk` `ParseMPK` for `ParseMPKAll` and loop over every returned `mpkDef` (mirror the def-gen loop). Test `TestRegenerateWidgetDocsMultiWidgetMPK` (uses the `testdata/expr-checker/widgets/Charts.mpk` fixture). Bug 9a | -| Chart `series` datasource/attribute sub-properties don't parse — `staticDataSource: database from View` → `extraneous input 'from'`; and even if parsed, were silently dropped (`buildObjectListItem` `default: continue` for datasource) | Object-list item sub-properties of datasource/attribute type had no grammar, visitor, or executor support (deferred at #538). The **BSON layer already handled it** (`widgetobj/builder.go` `overlayItemValue` datasource case) — only the upstream plumbing was missing | grammar `mdl/grammar/domains/MDLPage.g4` (`widgetPropertyV3` generic datasource alt + `propertyValueV3` QUOTED_IDENTIFIER) + visitor `mdl/visitor/visitor_page_v3.go` (generic datasource branch + quoted value) + executor `mdl/executor/widget_engine.go` (`buildObjectListItem` pre-pass, `datasource` case, `DataSource:` alias, empty-CT emission) + `mdl/backend/mutation.go`/`widgetobj/builder.go` (`EmptyTemplate` flag) + `mdl/executor/widget_defs.go` (`ItemPropertyMapping.DataSource` from `PropertyDef.DataSource`, bump `WidgetDefGeneratorVersion`) | Add `(IDENTIFIER\|keyword) COLON dataSourceExprV3` (before scalar generic branches; leading DATABASE/MICROFLOW/etc token disambiguates from `propertyValueV3`) + `QUOTED_IDENTIFIER` to `propertyValueV3`; `make grammar`. Visitor stores the datasource under the property name and unquotes quoted values. In `buildObjectListItem`: a pre-pass resolves datasource sub-props (setting the item's entity context so `staticXAttribute` resolves against the SERIES' own datasource, not the parent); the friendly `DataSource:` alias routes to `staticDataSource`/`dynamicDataSource` by dataSet mode; emit the pre-built datasource from the `prebuiltDataSources` map (not the child.Properties lookup, which misses the alias). **CE0463 nuance**: a VISIBLE-but-unset texttemplate series sub-prop (staticName always in static mode; static·/dynamic· names once their datasource is configured) must serialize an empty `Forms$ClientTemplate`, not null. Visibility = dataSet-mode gate (from editorConfig `hideNestedPropertiesIn`) + dataSource-binding gate; scoped to SERIES/LINE containers so Accordion `GROUP`/DataGrid columns are untouched. **Diagnose CE0463** with the update-widgets diff: dump the page, `mx update-widgets`, dump again, `diff` the series WidgetObject — the delta was one `TextTemplate: null` → empty CT. The residual chart CE0463 (bare chart too, on a version-mismatched mxbuild) is orthogonal WidgetType drift (#529/#600) that `mxcli docker check`/`build` fix via update-widgets. Tests: `chart_series_test.go`, `visitor_chart_series_test.go`; bug-test `mdl-examples/bug-tests/bug9-chart-series-datasource.mdl`; spec `mdl-examples/doctype-tests/34-chart-widget-examples.mdl`. Bug 9a | -| An **invalid enumeration value** on a pluggable-widget object-list sub-property passes `mxcli check` but silently defaults in Studio Pro/mxbuild — e.g. a Maps marker `LocationType: 'coordinates'` (real values are `address`/`latlng`) defaults to `address` → CE "a dynamic marker requires an address"; a chart series `Interpolation: 'wobbly'` degrades. The value is dropped with no diagnostic | The def.json never captured a property's enumeration member keys, so `check` had nothing to validate against. (This is *not* a persistence bug — a **valid** enum value round-trips fine.) | mpk parser `sdk/widgets/mpk/mpk.go` (`PropertyDef.EnumValues` from ``) + `mdl/executor/widget_defs.go` (`ItemPropertyMapping.EnumValues`, bump `WidgetDefGeneratorVersion`) + validator `mdl/executor/validate_widgets.go` (`validateObjectListItemEnums`) | Capture `` keys into `PropertyDef.EnumValues` at all three PropertyDef build sites; carry into `ItemPropertyMapping.EnumValues`; add **MDL-WIDGET08**: for each object-list-item enum sub-property present in the AST, flag a value (case-insensitive) outside the member set, listing the valid values. Thread the parent's `ObjectListMapping` into `validateWidgetTreeIn` (was just the container-keyword set). Tests: `TestValidateObjectListItemEnums`, `TestParseMPK_CapturesEnumValues` (the MDL-WIDGET08 check needs project context/`-p`, so it can't be a `.fail.mdl` — `make check-mdl` runs those syntax-only; example `32-pluggable-widget-object-lists-v010.mdl` OL06/OL07 exercise the valid `latlng` path end-to-end). NOTE: top-level (non-object-list) widget enum props aren't validated yet — `PropertyMapping` doesn't carry `EnumValues`. Bug 9a follow-up | -| `ALTER ENTITY … MODIFY ATTRIBUTE X: Type NULLABLE` reports "Modified attribute" but the attribute stays NOT NULL — no way to make a required attribute optional. Any specified constraint (NOT NULL / UNIQUE / DEFAULT) is likewise ignored; there is no `NULLABLE` keyword, and it "parsed" only because trailing tokens were swallowed | Three-layer drop: (1) the visitor's MODIFY branch captured only `CALCULATED`; (2) `AlterEntityStmt` had no constraint fields; (3) the executor only set `attr.Type`. NOT NULL / UNIQUE are stored as **entity ValidationRules** (`Type:"Required"`/`"Unique"`, keyed by AttributeID), not attribute flags — so making nullable means **removing** the Required rule. Gotcha: the modelsdk read sets `ValidationRule.AttributeID` to the qualified name (`Mod.Ent.Attr`) while a created attr's ID is a UUID, so matching by ID alone fails | grammar `mdl/grammar/MDLLexer.g4` (`NULLABLE` token) + `MDLDomainModel.g4` (`attributeConstraint`) + `MDLSettings.g4` (keyword lists) + AST `mdl/ast/ast_entity.go` (`ModifyNotNull *bool` etc.) + visitor `mdl/visitor/visitor_entity.go` (MODIFY constraint switch) + executor `mdl/executor/cmd_entities.go` (`setAttributeValidationRule`, `ruleTargetsAttribute`) | Add a real `NULLABLE` token (+ to both `keyword` lists so it stays a usable identifier) and an `attributeConstraint` alt; `make grammar`. **Semantics: apply-specified, preserve-unspecified** (non-breaking — the documented "DEFAULT survives a type change" behavior stays): NULLABLE→`ModifyNotNull=&false` (drop Required rule), NOT NULL/REQUIRED→`&true` (re-add), UNIQUE→add Unique rule, DEFAULT→set value; nil pointer = leave as-is. Match the existing rule via `ruleTargetsAttribute` (UUID **or** last-dot-segment name, since read-back rules use the qualified name); store new rules with the qualified name (`validationRuleToGen` writes a dotted AttributeID verbatim). Verify with `describe entity` + `mx check`=0. Bug-test `mdl-examples/bug-tests/bug12a-modify-attribute-nullable.mdl`; tests `TestModifyAttributeConstraintsCaptured`, `TestRuleTargetsAttribute`, `TestSetAttributeValidationRule`. Bug 12a | -| `MOVE ENUMERATION … TO FOLDER` prints "Moved … to new location" but `DESCRIBE ENUMERATION` and `CATALOG.ENUMERATIONS.Folder` still show the module root (looks like a silent no-op) | **NOT a write bug** — the move persists (the enum's `Unit.ContainerID` becomes the folder; `show enumerations` shows it correctly). Two *read* paths misreported it: (1) `buildEnumerations` hardcoded the catalog Folder to the module name (`// Folder as module name for now`) instead of `buildFolderPath` — same latent bug in `builder_microflows.go`/`builder_rest.go`/nanoflows; (2) DESCRIBE ENUMERATION emitted no `FOLDER` clause (unlike DESCRIBE PAGE), and CREATE ENUMERATION had no folder grammar to round-trip one | catalog `mdl/catalog/builder_modules.go` (+ microflows/rest) + grammar `MDLDomainModel.g4` (`enumerationOption` `FOLDER`) + AST `ast_enumeration.go` (`Folder`) + visitor `visitor_enumeration.go` + executor `mdl/executor/cmd_enumerations.go` (`execCreateEnumeration` via `resolveFolder`, `describeEnumeration` emit) | Catalog: replace hardcoded `moduleName` Folder with `b.hierarchy.buildFolderPath(x.ContainerID)`. Grammar: add `FOLDER STRING_LITERAL` to `enumerationOption` (parity with pages' `Folder:`); place the enum via the shared `resolveFolder(ctx, moduleID, folder)`; DESCRIBE emits `) FOLDER 'path';` when `BuildFolderPath != moduleName`. **Diagnosis tip**: when a MOVE "doesn't persist," check the raw `Unit.ContainerID` and `show ` before assuming a write bug — a hardcoded catalog column or a missing DESCRIBE clause is a read-path illusion (cf. #722). Bug-test `bug12b-enumeration-folder.mdl`; tests `TestCreateEnumerationFolderCaptured`. Bug 12b | -| `CREATE PAGE` with a widget `Action: nanoflow …` fails on the **default (modelsdk)** engine: `client action *pages.NanoflowClientAction not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy` (legacy works). Blocks any nanoflow-triggered button/link written via MDL | `clientActionToGen` had cases for save/cancel/close/delete/page/microflow/create-object client actions but **no `*pages.NanoflowClientAction` case** → hit the `default` refuse-loudly branch. (The sibling microflow-arg drop was the separate Bug 1, already fixed on `microflowSettingsToGen`.) | `mdl/backend/modelsdk/widget_write.go` → `clientActionToGen` | Add a `*pages.NanoflowClientAction` case building a gen `CallNanoflowClientAction`. Unlike the microflow action (which nests a `Forms$MicroflowSettings`) it carries the Nanoflow name + `ParameterMappings` **directly**: `SetNanoflowQualifiedName`, `SetProgressBar("None")`, `SetDisabledDuringExecution(true)`, and one `NanoflowParameterMapping` per arg (`SetParameterQualifiedName(nanoflow+"."+param)` BY_NAME + `SetExpression($var)`); register `Forms$CallNanoflowClientAction` TypeDefaults (`MandatoryLists:[ParameterMappings]`, `NullFields:[ProgressMessage,ConfirmationInfo]`). Mirror the legacy `writer_widgets_action.go`. The modelsdk **read** already round-trips it (describe shows `nanoflow M.NF(Form: $Form)`). Verify both engines with `mx check`=0. Bug-test `bug2-modelsdk-nanoflow-widget-action.mdl`; tests `TestNanoflowClientAction_Serialized` (+ `TestMicroflowClientAction_PersistsParameters` guards Bug 1's mapping stays wired through the shared helper). Bug 2 | -| A `create page` over a layout with **more than one placeholder** (e.g. Main + Right/Topbar) can only populate Main — there is no MDL syntax to target a second placeholder, so the layout renders wrong | The page body was a flat widget list; the executor built exactly **one** `Forms$FormCallArgument` for the Main placeholder (`getMainPlaceholderRef` → hardcoded `".Main"`). NOT a BSON/engine gap — placeholders are BY-NAME (`Parameter="."`) and `layoutCallToGen` already iterates *multiple* arguments; the whole gap was upstream (grammar/AST/visitor/executor produced a single arg). DESCRIBE also *flattened* every argument's widgets into one list | grammar `mdl/grammar/domains/MDLPage.g4` (`placeholderBlockV3`) + AST `mdl/ast/ast_page_v3.go` (`PagePlaceholderV3`) + visitor `mdl/visitor/visitor_page_v3.go` (`buildPagePlaceholdersV3`) + executor `mdl/executor/cmd_pages_builder_v3.go` (one FormCallArgument per placeholder) + describe `mdl/executor/cmd_pages_describe.go` (`getPageWidgetGroupsFromRaw`) | Add a `placeholder { … }` block (name = `identifierOrKeyword` so `Right`/`Left`/`Content` parse); bare widgets still bind to Main (backward-compatible). `make grammar`. Executor groups widgets by placeholder (Main = bare body + any `placeholder Main` block) and emits one arg each with `ParameterID = "."` and a numbered `conditionalVisibilityWidget` wrapper. DESCRIBE groups by argument's `Parameter` and emits explicit `placeholder` blocks only when >1 placeholder has widgets (single-Main stays bare → no test churn). Verified both engines `mx check`=0; stable describe→exec→describe round-trip. Bug-test `mdl-examples/bug-tests/532-page-layout-placeholders.mdl`; tests `TestPagePlaceholderBlocksParsed`, `TestPageWithoutPlaceholdersUnaffected`. Layout placeholder names come from the layout's `Forms$Placeholder` widgets (e.g. Atlas_SideBar = Main + Topbar). Issue #532 | -| `GRANT VIEW ON PAGE Module.Page TO Module.Role` prints success but the page's allowed roles appear to stay empty: `mxcli lint` still reports `CE0557`/`MPR007` ("used as home page … but has no allowed roles") across a freshly rebuilt catalog, while entity-access grants on the same roles verify fine (`describe entity` shows them). Reported on **modelsdk** engine (default), Mendix 11.11 | NOT a write bug — the grant **does** persist (`AllowedModuleRoles: [3, "Module.Role"]` on disk; `mx check` = 0 errors; Studio Pro would load clean). The regression was on the **read** path: modelsdk `pageFromGen` didn't populate `Page.AllowedRoles`, and `mxcli lint`'s CE0557 rule counts `len(pg.AllowedRoles)` from `ListPages()` — so it read every page as having zero roles and false-fired. `SHOW ACCESS ON PAGE` / `SHOW SECURITY MATRIX` under-reported the same way. The entity-vs-page asymmetry the reporter saw = different read paths (entity `describe` worked; page `lint` didn't), not different writes | `mdl/backend/modelsdk/page.go` → `pageFromGen` | Already fixed by `88a2d50b1` (issue #722): populate `out.AllowedRoles` from the gen `AllowedRolesQualifiedNames()`, mirroring `microflowFromGen`. Guard for the reporter's exact end-to-end flow: `TestUpdateAllowedRoles_PagePersistsForLintRead` (`mdl/backend/modelsdk/page_roles_test.go`) writes a page role then reopens fresh and asserts the lint read path (`ListPages`→`AllowedRoles`) sees it. **Diagnosis tip**: when a write "reports success but the value stays empty" *only in a read/lint/show command*, dump the raw unit (`mxcli bson dump`) — if the bytes are there, it's a read-adapter (`*FromGen`) gap, not a write bug; confirm by reverting the suspected read commit and re-running | -| `MOVE ENTITY X to OtherModule` where a **view entity's OQL** joins through `X`: passes `mxcli check`, but `mx check` on **Mendix 10.x** reports `CE0174 "…path '…/DmTest.Customer' does not resolve to an entity"` at the view entity — **only on the modelsdk engine**, only on 10.x (11.x is clean; legacy is clean) | Mendix <11 stores a view entity's OQL in **two** places: the top-level `DomainModels$ViewEntitySourceDocument` unit AND inline on the entity's `DomainModels$OqlViewEntitySource` (see `entityToGen`, gated `major < 11`). modelsdk's `UpdateOqlQueriesForMovedEntity` rewrote only the source document, leaving the inline copy pointing at the old module. 11.x doesn't write the inline copy (OQL lives only on the source doc), so it was never affected — hence 10.x-only. Legacy updates the inline copy incidentally by re-serializing the domain model | `mdl/backend/modelsdk/move_view_write.go` → `UpdateOqlQueriesForMovedEntity` | After the ViewEntitySourceDocument sweep, also iterate every domain model (`ListDomainModels` → `loadDomainModelGen`), find each entity whose `Source()` is `*genDm.OqlViewEntitySource` with an `Oql()` containing the old qualified name, `SetOql(strings.ReplaceAll(...))`, and `persistDM`. Mirrors the `UpdateEnumerationRefsInAllDomainModels` walk. **Diagnosis tip**: when a value updates in one storage location but a stale copy elsewhere still breaks `mx check`, dump raw `mprcontents` and grep for the value across `$Type`s — Mendix often duplicates a field (OQL, captions) and a version gate (`major < 11`) can make one copy version-conditional. Repro: `mdl-examples/bug-tests/move-entity-oql-view-inline-ref.mdl` | -| `MOVE ENTITY` / `MOVE ENUMERATION` cross-module prints `Warning: Could not update OQL queries` / `Could not update enumeration references: load domain model 00000000-0000-0000-0000-000000000002: open …/mprcontents/00/00/00000000-…002.mxunit: no such file or directory` — **modelsdk engine only**. The move itself succeeds and (today) user modules are still updated, because `ListDomainModels` appends the failing System DM **last** — but the warning is spurious and the ordering dependence is fragile | Both reference-update sweeps iterate `ListDomainModels()`, which **injects the virtual System-module domain model** (ID `…002`, `buildSystemDomainModel`) so platform entities resolve. Each sweep then re-loads every listed DM from disk via `loadDomainModelGen(info.ID)` — but the System module isn't stored in `mprcontents`, so its `.mxunit` read fails. Legacy never hit this: it walks in-memory `*domainmodel.DomainModel` objects and never re-reads from disk | `mdl/backend/modelsdk/association_move_write.go` → `UpdateEnumerationRefsInAllDomainModels`; `mdl/backend/modelsdk/move_view_write.go` → `UpdateOqlQueriesForMovedEntity` | Skip the System DM at the top of each DM loop: `if string(info.ID) == meta.SystemDomainModelID { continue }` (import `modelsdk/meta`). It never holds user enum refs or user view-entity OQL, so skipping is correct and removes the disk-read failure regardless of iteration order. **Diagnosis tip**: a "no such file / not found" on the fixed ID `…002` (or module `…001`) means code is trying to load the *virtual* System unit from disk — filter it out, don't try to read it. Regression: `TestUpdateRefsForMovedEntity_SkipsSystemDomainModel` (`move_refs_system_test.go`) | -| A page whose widget has a **conditional visibility/editability** setting fails to **load** on **Mendix 11.12** — `StorageLoadException: Conditional editability settings … has an invalid value '' for property Attribute. The text ' ' is not a valid AttributeIdentifier` (the `' '` is `\x03\x00\x00\x00` = an int32 list marker; sometimes the value is the object's own `$Type` string). **Both engines**, ≤ 11.11 tolerated it. Reproduces only in the full doctype gate, not minimally — a red herring: it looked like `mx update-widgets` (harness step) was the cause, but the corruption is on disk from mxcli. My pymongo scans missed it because the value is a non-string type / the reader mis-parses | mxcli serialized `Forms$ConditionalVisibilitySettings` / `ConditionalEditabilitySettings` `Attribute` as **BSON null** when there's no attribute-based condition. Studio Pro writes the **empty string `""`** (Attribute is a BY_NAME `AttributeIdentifier`). 11.12's stricter streaming reader rejects the null and mis-reads the following bytes. Found by comparing a Studio-Pro-authored settings node (`str ""`) against mxcli's (`NoneType`) | legacy: `sdk/mpr/writer_widgets.go` (`serializeConditionalVisibility`/`serializeConditionalEditability`); modelsdk: `mdl/backend/modelsdk/widget_write.go` (RegisterTypeDefaults) + new `codec.TypeDefaults.EmptyStringFields` | Emit `Attribute: ""` not null. Legacy: change the `bson.D` value. Modelsdk: move `"Attribute"` from `NullFields` to the new `EmptyStringFields` (encoder emits `""`). **Diagnosis pattern**: when mx reports a corrupt value that pymongo *can't see on disk*, and the value looks like a `$Type` string or `\x03\x00\x00\x00`, suspect a wrong BSON *type* for a field (null where a string is required) that the streaming reader mis-aligns — compare field types against a Studio-Pro-authored node, don't just compare key presence. Repro: `mdl-examples/bug-tests/conditional-settings-attribute-empty-1112.mdl` | -| A CLOSE PAGE microflow/nanoflow activity (`close page;`) fails `mx check` on **Mendix 11.12** with `CE0117 "Error(s) in expression"` at the Close page activity — **legacy engine only** (modelsdk passes; ≤ 11.6 tolerated it). Any annotation (`@caption`/`@color`/`@position`) on the activity reliably exposes it | The legacy `sdk/mpr` writer serialized the `Microflows$CloseFormAction` page-count under the field name `NumberOfPagesToClose`, but the metamodel storage name is `NumberOfPages` (the codec/modelsdk already used it — found by diffing the two engines' BSON: `legacy=NumberOfPagesToClose, modelsdk=NumberOfPages`). 11.12 doesn't recognise the old name, so the real `NumberOfPages` field is absent → defaults to an empty expression → CE0117 | `sdk/mpr/writer_microflow_actions.go` (`*microflows.ClosePageAction` case) + `sdk/mpr/parser_microflow_actions.go` | Write `NumberOfPages` (not `NumberOfPagesToClose`); the reader accepts both (`NumberOfPages` first, legacy `NumberOfPagesToClose` fallback) for round-trip fidelity on existing projects. **Diagnosis pattern**: a CE that says "invalid/empty expression" on an activity with no visible expression usually means a *renamed/missing* field defaulting to empty — diff the passing engine's BSON against the failing one to spot the field-name drift. Repro: `mdl-examples/bug-tests/close-page-numberofpages-1112.mdl` | -| On **Mendix 11.12**, `mx check` fails to load projects on the **legacy** engine for several doctypes (`01-domain-model`, `10-odata`, `14-project-settings`, `22-published-rest`) with `System.InvalidOperationException: Expected '$ID' as the first property of a storage object, but got 'X'` at `StreamingBsonUnitReader`. The bad first-key varies run-to-run (`ThemeModuleOrder`, `EnableRspackBundler`, `Oql`, `MarkAsUsed`, `PopupResizable`…) — the tell-tale of random map-key order. modelsdk passes; ≤ 11.11 tolerates any order | Several legacy `sdk/mpr` writers preserve round-trip fidelity by carrying parsed subtrees as Go maps (`ProjectSettings.RawParts`, ref-marking `raw`, rename `raw`, domain-model `raw`) and marshalling them back. `bson.Marshal` emits **map keys in random order**, so `$ID` only lands first by luck. 11.12 rejects any storage object whose first key isn't `$ID`. The earlier `$ID`-first fix (bson.D reorder in writer_domainmodel/security) missed every map-passthrough site | `sdk/mpr/writer_settings.go` (+ `writer_refs`, `writer_rename`, `writer_domainmodel`, `writer_odata`, `writer_rest`, `writer_customblob`, `writer_security`, `writer_modules`, `writer_microflow`, … — all unit-boundary marshals) via new `sdk/mpr/writer_order.go` | Wrap every unit-boundary `bson.Marshal(doc)` in `marshalUnitIDFirst(doc)` = `bson.Marshal(bsonutil.HoistStorageID(doc))`. **Use the non-sorting `HoistStorageID`, NOT `OrderStorageValue`**: `OrderStorageValue` *sorts* the non-`$ID` keys, which corrupts template-derived pluggable-widget/datagrid page trees (mx then aborts with `got 'LabelTemplate'`, regressing `29-datagrid`/`33-alter-page`). `HoistStorageID` only lifts `$ID`/`$Type` to the front and preserves every other key's order → no-op on already-correct writers, fixes only the map-passthrough ones. **Do NOT normalize `writer_pages.go` or the page/workflow mutators** — their output is already `$ID`-first and they embed delicate widget maps the hoist would sort-corrupt. Verify on 11.12 with the doctype gate; regression-check `29`/`33` specifically. (Left unfixed: `03-page` LabelTemplate — a separate pre-existing issue where on-disk bytes read `$ID`-first yet mx still objects) | -| A `NUMBERFILTER` in a DataGrid column passes `mxcli check` but corrupts the `.mpr`: MxBuild / Studio Pro **fails to load** the whole project on **Mendix 11.12** with `System.InvalidOperationException: Type …CustomWidgets.WidgetProperty does not contain a constructor with a parameter of type …CustomWidgets.WidgetValue` at `StreamingBsonUnitReader.ConstructObject`. `TEXTFILTER`/`DATEFILTER`/`DROPDOWNFILTER` in identical grids load fine; ≤ 11.11 loads even the number filter. Isolating it needs dropping widgets one-by-one under `mx check` | The embedded `datagrid-number-filter.json` template authored its 3 placeholder / screen-reader `Forms$ClientTemplate` blocks with **markerless empty arrays** — `"Items": []` and `"Parameters": []`. Every Mendix list serializes with a leading marker int (`Texts$Text.Items`→`[3]`, `ClientTemplate.Parameters`→`[2]`, `Widgets`/`Objects`→`[2]`); a bare `[]` has no marker. 11.12's stricter streaming reader mis-parses the markerless array and mis-associates the following bytes, so a nested `WidgetValue` lands where the reader is constructing a `WidgetProperty`. The template shape is otherwise identical to the working filters — the only diff is the missing markers (the blocks had hand-authored `dd2b3c4d…` placeholder IDs, i.e. not cleanly extracted from Studio Pro) | `sdk/widgets/templates/mendix-11.6/datagrid-number-filter.json` **and** `modelsdk/widgets/templates/mendix-11.6/datagrid-number-filter.json` (both engines embed their own copy) | Add the marker int to every empty `TextTemplate` array: `"Items": []`→`"Items": [3]`, `"Parameters": []`→`"Parameters": [2]`. Rebuild (`go:embed`), re-exec, verify with `scripts/mx-check.sh --version 11.12.0` (0 errors; regression-check 11.9 still clean). Guard: `TestTemplates_NoMarkerlessEmptyArrays` (in both `sdk/widgets` and `modelsdk/widgets`) walks every embedded template and fails on any bare `[]` — a Mendix list must always carry its marker. Repro: `mdl-examples/bug-tests/datagrid-numberfilter-array-marker.mdl`. **Diagnosis tip**: when a widget "passes check, fails 11.12 load" with the WidgetProperty/WidgetValue constructor error, suspect a markerless empty array in the template (shape-diff the failing widget's template against a working sibling; the smoking gun is `[]` vs `[]`) | -| `mxcli check --references` flags `attribute 'Type' is a reserved word (CE7247) [MDL021]` even though the attribute is **quoted** (`"Type": String`), and the skills say "always quote to avoid reserved-word conflicts" — tester assumed quoting should exempt it | NOT a bug — the check is correct. Quoting only escapes **MDL parser** keywords (`unquoteString` strips the quotes and the *bare* name is validated). `Type`, `ID`, `GUID`, `CurrentUser`, and the audit names `CreatedDate`/`ChangedDate`/`Owner`/`ChangedBy` are reserved by the Mendix **platform**, so they fail regardless of quoting. The gap was documentation: several skills claimed quoting is "always safe" without the platform-name carve-out | `mdl/executor/cmd_enumerations.go` (`mendixReservedWords`, `mendixSystemAttributeNames`) — no code change needed; docs only | Add the carve-out to the "always quote" guidance (`check-syntax.md`, `generate-domain-model.md`, `demo-data.md`, docs-site `lexical-structure.md`) and a CLAUDE.md note: quoting is *parser*-safe, not *platform*-safe. Rename `Type`→`ResourceType`; use `AutoCreatedDate`/… pseudo-types for audit fields. Adjacent Mendix rule documented alongside: the after-startup microflow must return `Boolean` (CE0142) — a void seed microflow fails the build | -| A clickable `CONTAINER` can't be expressed: `OnClick: MICROFLOW …` / `Click:` error in the parser (`mismatched input 'MICROFLOW' expecting {',', ')'}`), and the one form that parses (`Action: MICROFLOW …`) is silently dropped — the container's BSON always has `OnClickAction = Forms$NoAction` | Layers all hardcoding "no click": (1) only the `Action:` keyword's `actionExprV3` accepted an action value, so `OnClick:`/`Click:` had no rule taking `MICROFLOW …`; (2) `buildContainerV3` never read `GetAction()`; (3) the `Container` model had no `OnClickAction` field and both writers (`serializeContainer`, modelsdk `widgetToGen`) emitted `serializeClientAction(nil)`/`noActionGen()`; (4) DESCRIBE never read it back | grammar `mdl/grammar/domains/MDLPage.g4` (`widgetPropertyV3`) + `mdl/visitor/visitor_page_v3.go` (`buildWidgetPropertyV3`) + `sdk/pages/pages_widgets_container.go` (`Container`) + `mdl/executor/cmd_pages_builder_v3_layout.go` (`buildContainerV3`) + `sdk/mpr/writer_widgets_layout.go` (`serializeContainer`) + `mdl/backend/modelsdk/widget_write.go` (DivContainer case) + `mdl/executor/cmd_pages_describe_parse.go`/`cmd_pages_describe_output.go` | Add an `ONCLICK COLON actionExprV3` alternative aliasing `Action:` (`make grammar`), stored under the same `Properties["Action"]` key. Add `OnClickAction ClientAction` to `Container`; in `buildContainerV3` read `GetAction()` → `buildClientActionV3()`; both writers serialize the real action (nil falls back to NoAction, so non-clickable containers are unchanged). DESCRIBE: extract `w["OnClickAction"]` via `extractButtonAction` (synthesize `{"Action": …}`) and emit `Action: ` in the container output. Verified with `mx check` = 0 errors. `Forms$DivContainer.OnClickAction` is required & introduced 8.3.0, so no version gate. Issue #603 | -| A **quoted** association/attribute name in a microflow `SET`/`Change` target (`SET $x/Module."Assoc" = $y`, following the "always quote identifiers" guidance) passes `mxcli check` and `exec` but corrupts the `.mpr` — Studio Pro/MxBuild fails to load with `StorageLoadException: … 'Module."Assoc"' is not a valid AttributeIdentifier`. The unquoted form (`SET $x/Module.Assoc = $y`) is fine | The SET target was captured via `ap.GetText()` (raw source, quotes intact) and later sliced on `/` into the member name; `resolveMemberChange` then compared the quoted `"Assoc"` against the unquoted domain-model `a.Name`, never matched, and wrote the quoted string verbatim into `AttributeQualifiedName`. Attributes in `CREATE (…)` were unaffected because that path unquotes per-segment | `mdl/visitor/visitor_microflow_statements.go` (`buildSetStatement`) + `mdl/visitor/visitor_microflow_actions.go` (`buildAttributePathFromContext`, `attributePathTargetText`) + `mdl/executor/cmd_microflows_builder_actions.go` (`resolveMemberChange`, `isValidMemberIdentifier`) | Build the SET target from the structured path with each qualified-name segment unquoted (`getQualifiedNameText`, not `qn.GetText()`), so `Target` is normalized (`$x/Module.Assoc`) at parse time for every downstream consumer (check + exec). Defense-in-depth: `resolveMemberChange` rejects a non-empty member name that isn't a valid identifier (`isValidMemberIdentifier`) so a leaked quote errors instead of corrupting. Tests: `mdl/visitor/visitor_microflow_set_quoted_test.go`, `mdl/executor/cmd_microflows_member_identifier_test.go`. Repair a corrupted microflow in place with `CREATE OR REPLACE MICROFLOW …` (unquoted form) | -| `set $IntVar = $a * 100 div $b;` (or `declare $I Integer = $a div $b;`) passes `mxcli check` but `mx check` rejects it with **CE0117** — Mendix integer division (`div`) yields a Decimal, so the target must be Decimal (or the result rounded) | `mxcli check`'s expression checker (`mdl/exprcheck`) was only wired into the Starlark linter, not `mxcli check`, and `inferKind` didn't type arithmetic operators (`div`/`*`/`-`/`mod` fell through to `KindUnknown`); nothing compared a SET/declare value's kind against the Integer/Long target | `mdl/exprcheck/parser.go` (`inferKind` + `arithResult`), `mdl/exprcheck/infer.go` (`InferSourceKind`, `SourceIsArithmeticDecimal`), `mdl/executor/validate_microflow.go` (`ValidateMicroflow` → MDL041) | Type `div`→Decimal and propagate Decimal through `+`/`-`/`*`/`mod` (`arithResult`); add **MDL041** in `ValidateMicroflow` (runs in syntax-only `check`, no project) flagging a Decimal-valued *arithmetic* expression assigned to an Integer/Long var. Narrow to arithmetic roots only via `SourceIsArithmeticDecimal` — a rounding-function result (`round(sqrt($x))`, `floor($a div $b)`) is accepted by Mendix into an Integer and must NOT be flagged (that was a false positive on `04-math-examples.mdl`). Tests: `mdl/exprcheck/infer_test.go`, `mdl/executor/validate_microflow_div_test.go` | -| `linkbutton` (a documented CREATE PAGE / ALTER PAGE INSERT widget) fails `exec` with "unsupported widget type: linkbutton — refresh widget definitions", even though `actionbutton` works in the same spot | `linkbutton` had a grammar token, a `pages.LinkButton` stub, and docs, but **no builder and no serializer** — `buildWidgetV3`'s switch only had `button`/`actionbutton`, so it fell to `default`. Trap: the `Forms$LinkButton` metamodel type requires an `address` (a legacy static hyperlink); the documented `linkbutton (caption, action)` is really a `Forms$ActionButton` with **RenderType "Link"** (the toolbox "link button"). The `RenderMode` field already existed on `pages.ActionButton` but the serializer hardcoded `RenderType: "Button"` | `mdl/executor/cmd_pages_builder_v3.go` (switch) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildButtonV3`) + `sdk/mpr/writer_widgets_display.go` (`serializeActionButton`) + `mdl/backend/modelsdk/widget_write.go` (ActionButton case) + `mdl/executor/cmd_pages_describe_parse.go`/`cmd_pages_describe_output.go` | Route `linkbutton` → `buildButtonV3` with `RenderMode = Link`; serialize `RenderType` from `ab.RenderMode` (default "Button") on both engines; DESCRIBE reads `RenderType` back and emits the `linkbutton` keyword when it is "Link". Reuses the proven `Forms$ActionButton` BSON (only the enum differs), so no CE0463 risk. Tests: `mdl/executor/cmd_pages_linkbutton_test.go`, `sdk/mpr/writer_widgets_linkbutton_test.go`; example in `mdl-examples/doctype-tests/03-page-examples.mdl` | -| In an interactive REPL running scripts back-to-back, a script that completes fine (e.g. `11-navigation-examples.mdl`, which contains `REFRESH CATALOG FULL`) leaves the session **silently disconnected** — the *next* `execute script` (or any statement) fails with `not connected to a project`. Only reproduces when a `.mxcli/catalog.db` already exists on disk (from a prior session/script) AND the project has since been written to; a from-scratch piped run never hits it | `REFRESH CATALOG FULL` finds the on-disk cache stale (`Cache invalid: project file modified`) and calls `reconnect(ctx)`, which swaps `e.backend` for a fresh connection and syncs it back. When that reconnect fires **inside `execute script`**, the *outer* script statement's `ExecContext` was snapshotted before the reconnect, so it still holds the pre-reconnect (now-closed) backend. `executeInner`'s `syncBack` after the script then clobbers `e.backend` with that stale, closed connection → `IsConnected()` false → next statement reports not-connected. Any handler that runs nested statements via `ExecuteFn`/`ExecuteProgramFn` (EXECUTE SCRIPT, SQL connector-gen) is exposed | `mdl/executor/executor_dispatch.go` → `executeInner`/`syncBack` | Before `syncBack`, snapshot `e.backend` prior to dispatch; if the handler didn't change its own `ectx.Backend` (`ectx.Backend == before`) but a nested call swapped `e.backend` (`e.backend != before`), adopt the live connection and its project-scoped caches (`Backend`, `MprPath`, `Cache`, `Catalog`) into `ectx` so `syncBack` preserves the reconnect instead of reverting it. **Diagnosis pattern**: "works in a from-scratch/piped run, disconnects only in a long interactive session" + the only state that nils the modelsdk reader is `Disconnect()` → suspect a stale outer `ExecContext` clobbering executor-global state changed by a *nested* statement; the trigger for the reconnect is a pre-existing `.mxcli/catalog.db` + a modified project. Regression: `TestReconnectInsideScript_KeepsConnection` (`reconnect_in_script_test.go`, integration-tagged) | -| A widget's `DynamicClasses: ''` passes `mxcli check` but is silently dropped on write — `describe page` shows `Class` but no `DynamicClasses`; on `alter page ... set DynamicClasses` a core widget instead hard-errors "widget has no pluggable Object" | `DynamicClasses` (a `Forms$Appearance` string, sibling of `Class`) was unwired at every layer: parked in the generic Properties map, never read by `applyWidgetAppearance`, no `BaseWidget` field, both writers hardcoded `DynamicClasses: ""`, mutator had no case (fell to the pluggable-property setter), and describe never surfaced it | `sdk/pages/pages_widgets.go` (`BaseWidget.DynamicClasses`) + `mdl/ast/ast_page_v3.go` (`GetDynamicClasses`) + `mdl/executor/cmd_pages_builder_v3.go` (`applyWidgetAppearance`) + `mdl/executor/widget_engine.go` (`isBuiltinPropName`) + both writers (`sdk/mpr/writer_widgets.go` `serializeAppearance`; `mdl/backend/modelsdk/widget_write.go` `newAppearance`) + describe (`cmd_pages_describe*.go`) + `mdl/backend/pagemutator/mutator.go` (`case "DynamicClasses"`) | Carry it on `BaseWidget` (the appearance path — one place — covers core + pluggable widgets since `applyWidgetAppearance` runs for both); thread it through the appearance serializer of **both** engines (it's just a sibling string of `Class`); add a mutator `case "DynamicClasses"` writing `Appearance.DynamicClasses`; add a read at each `appearance["Class"]` site + an emit in `appendAppearanceProps`. Add `"DynamicClasses"` to `isBuiltinPropName` so the pluggable explicit-property pass skips it and the unknown-property guard permits it. Bug-test `mdl-examples/bug-tests/widget-dynamicclasses.mdl`; `mx check` = 0 on both engines. | -| A widget's conditional visibility set via the **string / static-boolean** form — `Visible: ''`, `set Visible = ""`, or `Visible: false` — passes `check`+`exec` but is silently dropped (only the `[ … ]` bracket form worked) | The visitor routes a bracket constraint to `Properties["VisibleIf"]` (consumed by `applyConditionalSettings`) but any other value to `Properties["Visible"]`, which nothing consumed; the ALTER mutator's `case "Visible"` wrote a bare `Visible` string (a DataGrid2-column alias) that Studio Pro ignores on a general widget. A page widget has **no** plain boolean Visible field — visibility is always `ConditionalVisibilitySettings` | `sdk/pages/pages_widgets.go` (`StaticVisibleExpression`) + `mdl/executor/cmd_pages_builder_v3.go` (`applyConditionalSettings`) + `mdl/backend/pagemutator/mutator.go` (`case "Visible"`) | Add `pages.StaticVisibleExpression(v)` mapping the value to a client expression: `false`→`"false"`, an expression string passes through (caller roots it; the bracket form auto-roots), `true`/`""`→"no settings / clear". CREATE's `applyConditionalSettings` reads `Properties["Visible"]` as a fallback after `VisibleIf`; the ALTER mutator's `case "Visible"` builds the `ConditionalVisibilitySettings` node via `setWidgetConditionalSettingMut` (or clears it to nil for `true`). Both engines: CREATE via the shared model→`applyWidgetBase`, ALTER via the shared raw-BSON mutator. describe converges every form to the canonical `Visible: [expr]`. NB datagrid **column** visibility is a separate `columnPropertyAliases` path — untouched. Bug-test `mdl-examples/bug-tests/widget-visible-expression.mdl`; `mx check` = 0 on both engines. | -| An unrecognized property on a **built-in** (non-pluggable) widget — a typo like `Contnet`, or a genuinely unsupported key — passes `mxcli check`+`exec` with no error and is silently dropped on write (pluggable widgets already got MDL-WIDGET01) | `validateStaticWidget` had no unknown-property check; core widgets have no single property registry — builders read keys imperatively, and `describe` even emits keys (`WidthUnit`) the native builder doesn't consume — so a hard reject would false-positive on valid MDL and break the describe→create roundtrip | `mdl/executor/validate_widgets.go` (`staticWidgetKnownProps`, `validateStaticWidgetUnknownProps`, wired in `validateWidgetTree` gated on `lookupWidgetDef==nil`) | Add **MDL-WIDGET07** as a **WARNING** (never an error — the core-widget vocabulary can't be proven complete): flag any `Properties` key not in `staticWidgetKnownProps` (the union of grammar keyword props + builder-consumed keys + the full `describe`-emit vocabulary, harvested by grep), with a `nearestKey` "did you mean" hint. Runs only for non-pluggable widgets. Guard the allow-list against describe-vocabulary drift with `TestStaticWidgetKnownPropsCoverDescribe`, and sweep `mdl-examples/**` for zero false positives after any change. Bug-test `mdl-examples/bug-tests/widget-unknown-property.mdl`. | -| A **quoted** attribute/association name inside an *expression* — a microflow decision/`if`/`while`/`return` (`if $Expense/"Justification" != empty`), a widget `contentparams` entry (`contentparams: [{1} = "Amount"]`), a `visible:`/`editable:` expression (`visible: [ "Amount" > 1000 ]`), or an **XPath database datasource WHERE** (`datasource: database from M.E where [ "Status" = 'Submitted' ]`) — passes `mxcli check` (and `--references`). The first three fail MxBuild "Error(s) in expression"; the **datasource WHERE is the worst variant** — it *also* passes MxBuild, then **silently returns zero rows at runtime** because Mendix reads the quoted `"Status"` as the string literal `'Status'`, making the constraint `'Status' = 'Submitted'` always false. The unquoted form is fine. Sibling of the SET/Change-**target** row above, but for expression contexts (the "always quote identifiers" guidance is parser-safe, not platform-safe here) | Two sub-causes. (a) Expression contexts stored the **raw source text verbatim** — `SourceExpr.Source` from `extractOriginalText` (microflow), `expr.GetText()` (contentparams), the serialized visibility string. (b) The **inline-bracket XPath** path built member names via `buildXPathQualifiedName` (`words[i].GetText()`, no unquote) → an `IdentifierExpr{Name:"\"Status\""}` that `xpathExprToString` re-emits verbatim. Either way the quotes leaked, whereas binding contexts route each identifier through `unquoteIdentifier`. The general (non-XPath) expression AST was already unquoted | `mdl/visitor/visitor_helpers.go` (`stripExpressionIdentifierQuotes`) + `mdl/visitor/visitor_microflow_statements.go` (`buildSourceExpression`, `buildXPathSourceExpression`, `buildRetrieveWhereExpression`) + `mdl/visitor/visitor_page_v3.go` (`buildConditionalExpression`, `buildParamAssignmentV3`) + `mdl/visitor/visitor_xpath.go` (`buildXPathQualifiedName`) | Strip double-quote/backtick identifier quotes at the source. For raw-text slots: `stripExpressionIdentifierQuotes` (tracks single-quote string state incl. `''` escapes, so `'he said "hi"'` is untouched — safe because in Mendix expr/XPath only identifiers are double-quoted). For the inline-bracket XPath AST: `unquoteIdentifier` each `xpathWord` in `buildXPathQualifiedName` (root fix, feeds datasource WHERE + inline retrieve + conditional visibility); guard the `empty`/`true`/`false` special-value switch to fire only on the **unquoted** form so a quoted `"empty"` stays a member name. `dbSource.XPathConstraint = ds.Where` verbatim, so the visitor value is the persisted BSON constraint. Not caught by the expression **type** checker (`PROPOSAL_expression_type_checking.md`) — it works on the clean AST, not the raw string, and is microflow-scoped. Tests: `mdl/visitor/visitor_expr_quoting_test.go`; bug-test `mdl-examples/bug-tests/expr-quoted-identifiers.mdl` | -| A `dynamictext`/`datagrid` **contentparams**/**captionparams** entry that navigates an association (`[{1} = Module.Assoc/Attr]`, e.g. `Expense_Employee/Name`) passes `mxcli check --references` but fails MxBuild "Value of text template parameter {1} … No value specified." A **direct** attribute (`[{1} = Title]`) persists fine — the drop is specific to association navigation | The value string (`Module.Assoc/Attr`) reached the AttributeRef serializer, which rejects any path with `< 2` dots (the association hop has 1 dot; `/Attr` is navigation, not counted) → **null AttributeRef** → CE0402. Nothing built the association-step structure Mendix needs. Verified against a Studio-Pro-authored page: the binding is a `DomainModels$AttributeRef` whose `Attribute` is the FINAL attr (`Module.Employee.Name`) and whose `EntityRef` is a `DomainModels$IndirectEntityRef` of `DomainModels$EntityRefStep{Association, DestinationEntity}` hops | `sdk/pages/pages_widgets_display.go` (`ClientTemplateParameter.AttributeRefSteps`, `AttributeRefStep`) + `mdl/executor/cmd_pages_builder_v3.go` (`resolveTemplateAssociationPath`, `associationEndpoints`) + `mdl/backend/modelsdk/widget_write.go` (`attributeRefWithStepsToGen`) + `mdl/executor/cmd_pages_describe_output.go` (`associationTemplateParamPath`) | **modelsdk engine only.** Resolver splits the path into association hop(s) + final attribute, resolves each association's destination entity from the domain model (ParentID=FROM, ChildID=TO; direction from the context entity), and stores `AttributeRef`=final attr QN + `AttributeRefSteps`. The writer emits `AttributeRef.EntityRef` via `entityRefToGen` (reused from microflows — identical `IndirectEntityRef`/`EntityRefStep` storage names); the `< 2 dots` guard no longer trips since the final attr is 2-dot. DESCRIBE reconstructs `Assoc/Attr` from `EntityRef.Steps`. Legacy engine still drops it (out of scope). Generated BSON is byte-structurally identical to Studio Pro. **Non-String final attribute needs NO `toString()`** — verified against Studio Pro, an association-navigated param uses `AttributeRef` + `FormattingInfo` (DecimalPrecision/DateFormat/EnumFormat) for any type; the `toString()` wrapping is only the *direct*-attribute path's behavior. Do not "fix" the assoc path to wrap non-String — that diverges from Studio Pro. Tests: `mdl/backend/modelsdk/widget_template_assoc_test.go`, `mdl/executor/cmd_pages_describe_assoc_test.go`; bug-test `mdl-examples/bug-tests/dynamictext-contentparam-association.mdl` | -| Under the **default (modelsdk)** engine, any page widget with an **association datasource** (`datasource: $currentObject/Module.Assoc`, the master-detail idiom) fails at exec: `CreatePage: DataView source *pages.AssociationSource not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy`. Same for a nested `listview` | `customWidgetDataSourceToGen` (pluggable widgets) already handled `*pages.AssociationSource`, but `dataViewSourceToGen`/`listViewSourceToGen` had no case → hit the "not yet supported" default. (Legacy doesn't error but writes a **broken** `Forms$AssociationSource{EntityRef: nil}` in `serializeListViewDataSource`, so legacy is not a valid reference — tracked as **L1** in [`docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md`](../../docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md); do **not** fix legacy, it will be removed when modelsdk matures) | `mdl/backend/modelsdk/widget_write.go` (`associationSourceToGen`, wired into `dataViewSourceToGen`+`listViewSourceToGen`) + `mdl/executor/cmd_pages_describe.go` (`associationSourcePath`) + `cmd_pages_describe_parse.go` (DataView/ListView datasource cases) + `cmd_pages_describe_output.go` (`associationDataSourceExpr`) | **modelsdk engine only.** Extract the proven `AssociationSource` block from `customWidgetDataSourceToGen` into a shared `associationSourceToGen` (Forms$AssociationSource → `IndirectEntityRef` of `EntityRefStep{Association, DestinationEntity}` + optional page-param `SourceVariable`; `$currentObject` → nil source var) and add a `case *pages.AssociationSource` to the DataView and ListView source builders. Matches the canonical legacy `serializeAssociationSource` (writer_widgets.go) and the Studio-Pro-verified `EntityRefStep` structure — NOT the broken `serializeListViewDataSource` stub. DESCRIBE reconstructs `$currentObject/Module.Assoc` from `EntityRef.Steps`. **An association source is valid only on LIST-producing widgets (listview/datagrid/gallery/templategrid), NOT a plain DataView** — Studio Pro rejects a DataView association source ("cannot have a data source of type association"); the widget still exec-creates (valid BSON), so `check` flags it with **MDL-WIDGET08** (`validateStaticWidget`). Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go`, `mdl/executor/cmd_pages_describe_assoc_source_test.go`, `validate_widgets_test.go`; bug-tests `mdl-examples/bug-tests/assoc-datasource-modelsdk.mdl` (valid listview) + `dataview-association-source-rejected.fail.mdl` (MDL-WIDGET08) | -| `IF … ELSIF … ELSE … END IF` silently drops every ELSIF arm on write — DESCRIBE round-trips `if … else …` with the middle arms gone (read path unchanged by the fix ⇒ the arm was absent from the stored model — dropped on write); no error from `check`/`exec` (both engines) | `buildIfStatement` read only `exprs[0]`/`bodies[0]` and the trailing ELSE body; the grammar's `(ELSIF expression THEN microflowBody)*` pairs were never visited | `mdl/visitor/visitor_microflow_statements.go` (`buildIfStatement`) | Lower each ELSIF arm into a nested `IfStmt` in the ELSE branch of the arm before it (built innermost-first) — Mendix has no native elsif, nested exclusive splits are the canonical shape. Guarded by visitor tests (chain, no-else, plain-if regression) + `mdl-examples/bug-tests/745-elsif-arms-dropped.mdl`. Issue #745 | -| Under the **default (modelsdk)** engine, any page widget with an **association datasource** (`datasource: $currentObject/Module.Assoc`, the master-detail idiom) fails at exec: `CreatePage: DataView source *pages.AssociationSource not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy`. Same for a nested `listview` | `customWidgetDataSourceToGen` (pluggable widgets) already handled `*pages.AssociationSource`, but `dataViewSourceToGen`/`listViewSourceToGen` had no case → hit the "not yet supported" default. (Legacy doesn't error but writes a **broken** `Forms$AssociationSource{EntityRef: nil}` in `serializeListViewDataSource`, so legacy is not a valid reference — tracked as **L1** in [`docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md`](../../docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md); do **not** fix legacy, it will be removed when modelsdk matures) | `mdl/backend/modelsdk/widget_write.go` (`associationSourceToGen`, wired into `dataViewSourceToGen`+`listViewSourceToGen`) + `mdl/executor/cmd_pages_describe.go` (`associationSourcePath`) + `cmd_pages_describe_parse.go` (DataView/ListView datasource cases) + `cmd_pages_describe_output.go` (`associationDataSourceExpr`) | **modelsdk engine only.** Extract the proven `AssociationSource` block from `customWidgetDataSourceToGen` into a shared `associationSourceToGen` (Forms$AssociationSource → `IndirectEntityRef` of `EntityRefStep{Association, DestinationEntity}` + optional page-param `SourceVariable`; `$currentObject` → nil source var) and add a `case *pages.AssociationSource` to the DataView and ListView source builders. Matches the canonical legacy `serializeAssociationSource` (writer_widgets.go) and the Studio-Pro-verified `EntityRefStep` structure — NOT the broken `serializeListViewDataSource` stub. DESCRIBE reconstructs `$currentObject/Module.Assoc` from `EntityRef.Steps`. A `Forms$AssociationSource` is valid on **LIST-producing** widgets (listview/datagrid/gallery/templategrid). A plain **DataView** cannot use `Forms$AssociationSource` (MxBuild **CE6705**) — but the DataView *association* case is still valid via a **different** source type; see the "data from context over association" row below (an earlier fix wrongly rejected it with MDL-WIDGET08, now removed). Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go`, `mdl/executor/cmd_pages_describe_assoc_source_test.go`; bug-test `mdl-examples/bug-tests/assoc-datasource-modelsdk.mdl` (valid listview) | -| A **DataView bound to a to-one referenced object over an association** ("data from context", e.g. an inner DataView showing the referenced Employee's `Name`/`Email` inside an `Expense` DataView) can't be authored/round-tripped: `dataview dvEmp (datasource: $currentObject/Module.Assoc)` was rejected (MDL-WIDGET08), a bare nested `dataview { textbox (attribute: Name) }` bound children to the **parent** entity (CE1613), and `describe` dropped the source → describe/exec **silently strips** the association DataView (round-trip corruption) | A DataView over an association is **not** a `Forms$AssociationSource` (that's list-widgets-only; CE6705 on a DataView). It is a `Forms$DataViewSource` whose `EntityRef` is a `DomainModels$IndirectEntityRef` navigating the association (a DataViewSource *is* an `EntityPathSource`, so its EntityRef may be indirect — `generated/metamodel/types.go:558`). mxcli generated the wrong source type, then (Bug 5 v1) wrongly rejected the whole case | `mdl/backend/modelsdk/widget_write.go` (`dataViewContextAssociationSourceToGen`, wired into `dataViewSourceToGen`'s `*pages.AssociationSource` case) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (removed the `buildDataViewV3` association guard) + `mdl/executor/validate_widgets.go` (removed MDL-WIDGET08) + `mdl/executor/cmd_pages_describe_parse.go` (`Forms$DataViewSource` reads `EntityRef.Steps` via `associationSourcePath`) | **modelsdk engine only.** For a DataView association datasource, emit `Forms$DataViewSource` with `EntityRef = IndirectEntityRef{EntityRefStep{Association, DestinationEntity}}` (+ optional page-param `SourceVariable`; `$currentObject` → none) — mirrors `associationSourceToGen` but wraps the IndirectEntityRef in a DataViewSource. Children then bind to the destination entity. DESCRIBE reconstructs `$currentObject/Module.Assoc`. Removes the earlier MDL-WIDGET08 over-rejection. **When two widget kinds share a navigation but MxBuild accepts it on only one, the distinction is the source `$Type` wrapper, not the EntityRef inside it** — check `generated/metamodel/types.go` for the widget's allowed `DataSource` subtypes. mxbuild-validated (`mxcli docker check --no-update-widgets` = 0 errors) + full describe→re-exec round-trip. Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go` (`TestDataViewAssociationSource_Serialized`); bug-test `mdl-examples/bug-tests/dataview-context-association-source.mdl`. Bug 5 (reclassified) | -| **DataGrid2** (`datagrid`) columns fail MxBuild **CE0463** "widget definition changed" on the **default (modelsdk)** engine — reported for custom-content columns, but actually **every** column (attribute too). Legacy is fine | The column WidgetObject's `Properties` were serialized in **alphabetical** order (`alignment, allowEventPropagation, attribute, …`) instead of the template's `PropertyTypes` order (`showContentAs, attribute, content, dynamicText, …`). Studio Pro hashes the object structure against the type and flags CE0463 on any order mismatch. Cause: the object-list item builder (`mdl/backend/widgetobj/builder.go:274`) orders by `NestedKeyOrder`, falling back to alphabetical when empty; the **modelsdk** registry loader never captured it (`types.PropertyTypeIDEntry` had no such field), while the MPR/legacy loader did → legacy correct, modelsdk broken. Top-level widget props were unaffected (ordered by a different path), which is why only the nested column object-list broke | `mdl/types/widget_property_type.go` (`PropertyTypeIDEntry.NestedKeyOrder`) + `modelsdk/widgets/loader.go` (thread `nestedKeyOrder` through `jsonValueToBSONWithNestedObjectType`→`extractNestedObjectType`→`extractNestedPropertyTypes`, append in template array order) + `mdl/backend/modelsdk/widget_pluggable_write.go` (`convertPropTypeIDs` copies `NestedKeyOrder`) | Mirror the `sdk/widgets` loader's existing `nestedKeyOrder` capture in the parallel `modelsdk/widgets` loader (dedup on first occurrence, append in PropertyTypes array order), add the field to `types.PropertyTypeIDEntry`, and copy it in the modelsdk `convertPropTypeIDs`. **Diagnose column-order CE0463 by mapping each column WidgetProperty's `TypePointer` → the type's `WidgetPropertyType.PropertyKey` and comparing the sequence to the template's PropertyTypes order.** Verified: modelsdk column order now equals the template (and legacy) order for both attribute and custom-content columns, and **`mxcli docker check --no-update-widgets` (raw output, no widget normalization) = 0 errors** for the report's exact pattern (attribute column + custom-content dynamictext-over-association + custom-content actionbutton with `show_page`). The custom-content child-widget subtree needed no separate fix — ordering was the whole cause. Tests: `modelsdk/widgets/nested_key_order_test.go`; bug-test `mdl-examples/bug-tests/datagrid2-custom-content-column.mdl`. **To validate CE-class MxBuild errors locally: `mxcli setup mxbuild -p app.mpr --force` then `mxcli docker check -p app.mpr --no-update-widgets`** (the `--no-update-widgets` is essential — the default runs `mx update-widgets` which auto-normalizes pluggable widgets and masks a real CE0463) | -| Pluggable-widget datasource `sort by desc` (DataGrid2, Gallery; quoted or unquoted attr) round-trips as `asc` — `describe page` shows the direction flipped, runtime renders oldest-first, no error (silent wrong-order). Reproduces on the **default (modelsdk)** engine | A Pages/`Forms$GridSortItem` stores its direction under the BSON key **`SortDirection`** (authoritative: the reflection-generated codec type `modelsdk/gen/pages` GridSortItem writes/reads `SortDirection`). The default engine wrote it correctly, but the DESCRIBE readers looked up the wrong key `SortOrder` (that key is only correct for `Microflows$SortItem` / `DocumentTemplates$GridSortItem`) → always fell back to `asc`. The legacy `sdk/mpr` writer *also* emitted the wrong `SortOrder` key, so under `--engine legacy` Studio Pro ignored it and reverted to ascending at runtime too | `mdl/executor/cmd_pages_describe_pluggable.go` (`gridSortDirection` helper + 3 call sites) & `cmd_pages_describe_parse.go` (1 call site); writer `sdk/mpr/writer_widgets.go` (`SerializeCustomWidgetDataSource`) | Add `gridSortDirection(sortItem)` reading `SortDirection` with a `SortOrder` fallback (keeps pre-fix files readable); route all four grid-sort readers through it. Fix the legacy writer to emit `SortDirection`. When a sort/direction field seems misnamed, check the element's gen type in `modelsdk/gen/*/types.go` — different metamodel types genuinely use different keys (`SortDirection` for Forms/Pages grids, `SortOrder` for microflow/document-template sorts). Tests: `mdl/executor/cmd_pages_describe_sortdir_test.go`; bug-test `mdl-examples/bug-tests/bug8-datagrid-gallery-sort-desc.mdl`. Bug 8 | -| A **compound** (nested) design property authored inline on a widget — `designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large', 'margin-top': 'Medium']]`, e.g. the Atlas `Spacing` group, and any block copied in via `use building block` — is silently dropped on write: `check`, `exec`, **and `mx check`** all pass, but the nested styling is simply gone (only the flat toggle/option props survive). The **legacy** builder write path. Sibling of #668, which fixed the describe **read** side but not this CREATE write path | `serializeDesignProperties` switched on `p.ValueType` with cases for `toggle`/`option`/`custom` and a `default: continue` that dropped `compound` entirely. A compound property's value is itself a set of sub-properties stored in a `Forms$CompoundDesignPropertyValue`, whose `Properties` list has the SAME marker-prefixed `Forms$DesignPropertyValue` shape as the outer array — but nothing serialized it, so the whole entry (key included) vanished | `sdk/mpr/writer_widgets.go` (`serializeDesignProperties`) | Add a `case "compound"` emitting `{$ID, $Type: Forms$CompoundDesignPropertyValue, Properties: serializeDesignProperties(p.Compound)}` — recurse into the sub-entries, which the marker-prefixed array handling already covers. The AST (`DesignPropertyEntryV3.Nested`), visitor (`buildDesignPropertyEntryV3`), and builder (`astDesignPropToValue` → ValueType `"compound"`, `Compound`) all already produced the nested model; only the serializer's terminal switch dropped it. Verified: `mxcli docker check` = 0 errors on 11.12.1 (authoritative) + write→describe round-trip preserves `Spacing` on **both** engines. Test `TestSerializeDesignProperties_Compound` (`sdk/mpr/writer_widgets_test.go`); bug-test `mdl-examples/bug-tests/compound-designproperties.mdl`. **Diagnosis pattern**: when a value passes every check *including `mx check`* yet the construct is absent, suspect a terminal `default: continue`/`default:` drop in a type-switched serializer — the value never reaches BSON, so nothing downstream can complain | -| A `use fragment` / `use building block` (or a content `slot`) nested **inside a container/layout/dataview** — not at the page-body top level — fails `exec` with `unsupported widget type: USE_FRAGMENT` (or `USE_BUILDING_BLOCK`). The same ref at the top level of the page body works. So a reusable card/panel can't be placed inside a layout column — the natural usage | `expandFragments` (the sentinel-expansion pass) only ran on the **top-level** widget list (the two call sites in `execCreatePage`/`execCreateSnippet`); the layout/container builders build children via `buildWidgetV3` directly, which has no case for the `USE_FRAGMENT`/`USE_BUILDING_BLOCK`/`SLOT` sentinel types → falls to `default` "unsupported widget type" | `mdl/executor/cmd_pages_builder_v3.go` (`expandFragments`) | Make `expandFragments` **recurse into each widget's children** after expanding the top sentinel: `for _, e := range expanded { if len(e.Children) > 0 { e.Children, _ = pb.expandFragments(e.Children) }; result = append(result, e) }`. The tree is fully expanded to concrete widgets *before* `buildWidgetV3` runs, so no builder needs a sentinel case. Expansion is idempotent on concrete widgets, so the extra traversal of an already-expanded slot payload is harmless. Verified: nested cards inside a layoutgrid column exec + `mx check` = 0 on 11.12.1. Test `TestExpandFragments_NestedInsideContainer`; bug-test `mdl-examples/bug-tests/nested-fragment-expansion.mdl`. **Diagnosis pattern**: "works at top level, `unsupported widget type` when nested" = an AST pre-pass (expansion/normalization) that only walks the root list; make it recurse into `.Children` | -| A single `alter entity` with **comma-separated** `add attribute` clauses fails to parse — e.g. `add attribute A: integer default 9, add attribute B: integer default 9` → `no viable alternative at input '9'`. Looks like "only the first `add` can carry a `default`" but the real cause is the comma | `alterStatement`'s `ALTER ENTITY qualifiedName alterEntityAction+` had **no separator** — commas between actions weren't allowed at all; the error surfaced on the second clause's `default` value token, which misdirects. Sudoku findings #5 | `mdl/grammar/MDLParser.g4` (`alterStatement`, ALTER ENTITY alt) | Change `alterEntityAction+` → `alterEntityAction (COMMA? alterEntityAction)*` (optional comma, mirroring `entityOptions`); `make grammar`. Newline-separated actions still parse. Bug-test `mdl-examples/bug-tests/f5-alter-entity-comma.mdl`. **Diagnosis pattern**: a parse error on the *value* inside the *second* item of a list usually means the list rule lacks a separator, not that the value form is wrong | -| An `autonumber` attribute with **no seed** passes `mxcli check` but fails the build with **CE7247 "Value cannot be empty"** (`alter entity … add attribute X: autonumber` or in a `create`). Docs showed seedless `autonumber` | `ValidateEntity` had no autonumber-seed rule; the writer emits no `AttributeValue` when `!attr.HasDefault`, so Studio Pro has no start value. Sudoku findings #6 | `mdl/executor/cmd_enumerations.go` (`ValidateEntity`) | Add **MDL023** (error): `attr.Type.Kind == ast.TypeAutoNumber && !attr.HasDefault` → "autonumber requires a seed (`default N`)". Also fixed the skill docs (`mdl-entities.md`, `generate-domain-model.md`) to show `autonumber default 1`. Test `TestValidateEntityAutonumberNeedsSeed`; negative bug-test `f6-autonumber-seed.fail.mdl` | -| An AutoX audit pseudo-type declared under a non-matching name — `StartedAt: autocreateddate` — silently becomes the fixed system member `CreatedDate` (declared name discarded), and binding that member in a widget then fails the build with **CE1613 "attribute … no longer exists"** (it's a system member, not a bindable attribute) | The write path discards the identifier for AutoX types and `ValidateEntity` skipped them with no name check, so the rename + unbindable-member trap was silent. Sudoku findings #7 | `mdl/executor/cmd_enumerations.go` (`ValidateEntity`, `autoMemberNames`) | Add **MDL022** (warning): when an AutoX attr's name (case-insensitive) ≠ its canonical member (`owner`/`ChangedBy`/`CreatedDate`/`ChangedDate`), warn that the name is discarded and the member isn't widget-bindable — use a plain attribute you set yourself if a widget must show it. Test `TestValidateEntityAutoMemberRename` | -| `mxcli run` warm-loop ergonomics from Sudoku findings: (a #17) a **relative** `-p` fails with MxBuild's raw "should be an absolute path" + a Windows JSON sample; (b #15/#23) a **build failure in the watch loop** (incl. a SCSS compile error like `Expected expression. _x.scss 180:35`) printed only the generic `build failed: `, swallowing the real detail | (a) `cmd_run.go` passed `-p` straight through without `filepath.Abs`; (b) the watch path at `runlocal.go` printed only `build.Message` while `build.Raw` (the full serve `/build` body, which the cold-build path already prints) held the compiler/model detail | `cmd/mxcli/cmd_run.go` + `cmd/mxcli/docker/runlocal.go` (watch build-failed branch) | (a) `projectPath, _ = filepath.Abs(projectPath)` after reading the flag; (b) also print `strings.TrimSpace(string(build.Raw))` (indented) when it differs from `build.Message`, matching the cold path. **Diagnosis pattern**: when a dev-loop error is unhelpfully generic, check whether a `Raw`/full-body field is already captured and just not printed on that code path | -| A **DataGrid2 column bound to an associated attribute** (`column c (attribute: Order_Customer/Name)`) passes `mxcli check` but fails MxBuild **CE1613** "The selected attribute 'Module.Entity.Order_Customer/Name' no longer exists." — an own-entity `attribute: Name` works. (Feature gap: no way to show an associated attribute in a column.) | The grammar `attributePathV3` already accepts a bare `Assoc/Attr` path (module-qualified `M.Assoc/Attr` does not — bare only), but the reader flattened it (`resolveAttributePath` just prefixes the entity, leaving the `/` embedded → `Module.Entity.Assoc/Attr`) and both column serializers hardcoded `EntityRef: nil`. So the column stored a flat, unresolvable attribute path with no association step | `mdl/executor/cmd_pages_builder_v3.go` (`resolveAssociationAttributePath`, extracted from `resolveTemplateAssociationPath`) + `mdl/executor/widget_engine.go` (full-page column `attribute` case) + `cmd_pages_builder_v3_widgets.go` (`buildColumnSpecFromAST`, ALTER) + `mdl/backend/mutation.go` (`ObjectListItemProperty`/`DataGridColumnSpec` gain `AttributeRefSteps`) + `mdl/backend/widgetobj/builder.go` (`setAttributeRefField`+`attributeEntityRefBSON`) + `datagrid_column.go` (`buildColumnAttributeProperty`) + `mdl/executor/cmd_pages_describe_pluggable.go` (`columnAttributeFromRef`) | Reuse the DynamicText contentparam machinery: resolve the `/`-path to a final attribute QN + `[]pages.AttributeRefStep` (hop → destination entity via `associationEndpoints`), carry the steps on the column spec, and emit `AttributeRef.EntityRef = IndirectEntityRef` of `EntityRefStep{Association, DestinationEntity}` (raw-BSON `attributeEntityRefBSON`, mirroring the codec-form `attributeRefWithStepsToGen`). DESCRIBE reconstructs the **short** `Assoc/Attr` (short association names — `attributePathV3` rejects module-qualified associations). mxbuild-validated (`mxcli docker check --no-update-widgets` = 0 errors) + describe round-trip. Tests: `mdl/backend/widgetobj/widget_builder_attribute_ref_test.go`, `mdl/executor/cmd_pages_describe_column_assoc_test.go`; bug-test `mdl-examples/bug-tests/datagrid2-associated-attribute-column.mdl`. Bug 7 | -| A DataGrid2 column's `DynamicCellClass: ''` (per-cell dynamic CSS class) parses, passes `check` + `mxbuild`, but is **silently dropped** — `describe` shows no `DynamicCellClass`, the `columnClass` slot is written as an **empty** expression, runtime cell is unstyled. Both engines | The DataGrid `columns` object-list mapping (`itemPropertyAliases`) had aliases for `header←Caption`, `dynamicText←Content`, `width←ColumnWidth` but **none** for `columnClass`. `buildObjectListItem` looks up the schema key + its MDL aliases in the AST property bag (case-insensitive via `lookupProperty`), so `DynamicCellClass` never matched → the property fell through to the template's empty default. Cached `.mxcli/widgets/datagrid.def.json` files also had to regenerate to carry the new alias | `mdl/executor/widget_defs.go` (`itemPropertyAliases`, `columns.columnClass`) + `mdl/executor/widget_engine.go` (`WidgetDefGeneratorVersion` bump) | Add `"columnClass": {"DynamicCellClass"}` to the datagrid `columns` aliases (schema property is `type: "expression"` → `operationForType` → written via the expression branch). **Bump `WidgetDefGeneratorVersion`** (5→6) so existing projects' cached def.json auto-regenerate via `RefreshStaleWidgetDefinitions` and pick up the alias — a code-only alias add is invisible until the stamped def is refreshed. DESCRIBE already reads `columnClass`→`DynamicCellClass` (`cmd_pages_describe_pluggable.go`). Test: `TestObjectListItemAliases`; bug-test `mdl-examples/bug-tests/bug10-dynamic-css-classes.mdl`. Bug 10a | -| A standard widget's generic property written in **lowercase** (e.g. `dynamicclasses:` instead of `DynamicClasses:`) is silently dropped on write, though the canonical-case form persists. Documented lowercase examples (`create-page.md`) don't work | Generic (non-tokenized) widget properties are stored in `WidgetV3.Properties` under the **user's original casing** (`visitWidgetProperty` generic branch: `Properties[id.GetText()]`). The builder reads appearance props via `GetStringProp("DynamicClasses")` — an exact-case map lookup — so a lowercased key never matched. MDL property names are documented case-insensitive (mirrors `lookupProperty` in the widget engine, used by the pluggable path) | `mdl/ast/ast_page_v3.go` (`GetStringProp`) | Make `GetStringProp` case-insensitive: exact-match fast path, then a lowercased scan (mirrors `lookupProperty`). Fixes any generic-stored standard-widget property read via `GetStringProp`/`GetDynamicClasses`, not just DynamicClasses. Test: `mdl/ast/ast_page_v3_getprop_test.go`; same bug-test as 10a. Bug 10b | -| A `dynamictext` contentparam over an association fails mxbuild ("No value specified") — **re-reported as still-broken after the modelsdk fix**. Investigation: the contentparam is actually **already correct on the default (modelsdk) engine** in every *valid* container (datagrid custom-content column, `listview + database`, `dataview` over a page parameter — all mx check 0 errors). The only failing repro is a `dataview (datasource: database …)`, which is an **invalid Mendix construct** — a data view shows one object, so Mendix offers only Context/Microflow/Nanoflow/Listen sources, never Database (that's for list widgets). mxcli wrongly accepted it: modelsdk errored "not yet supported — rerun with legacy", and legacy silently wrote a `Forms$DataViewSource` that mxbuild rejects with **CE7007** "Selected value is not valid for entity" | mxcli had no check for a DataView database source (only the association case, MDL-WIDGET08). So the invalid construct routed users to the deprecated legacy engine, which produced a broken page. **Do not "fix" legacy** (`LEGACY_ENGINE_KNOWN_ISSUES.md`: legacy is being removed, not patched) and **do not implement dataview+database in modelsdk** (Mendix doesn't allow it) — reject it at check, sibling to MDL-WIDGET08 | `mdl/executor/validate_widgets.go` (`validateStaticWidget`, MDL-WIDGET09) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildDataViewV3` exec refusal) | Add **MDL-WIDGET09**: `dataview` + `database` source → error steering to a microflow/nanoflow source (or page parameter), or a list widget. Refuse it in `buildDataViewV3` too (both engines) so a bare `exec` can't create a broken page. When a construct fails only on legacy, first check whether it's valid on modelsdk in a *valid* container — the fix is usually a check-time rejection of the invalid form, not a legacy patch. Tests: `TestValidateStaticWidget_DataViewDatabaseSource`; bug-test `mdl-examples/bug-tests/dataview-database-source-rejected.fail.mdl` (negative). Bug 3 (re-report) | -| A **list** widget (listview/datagrid/gallery) reverse-navigating an association (`datasource: $currentObject/Module.Assoc` on a Customer showing its Orders) passes `mxcli check` but fails MxBuild **CE8812** "A grid association path must result in a list." — **only** when the association is `owner both` **and** its FROM entity has another association. Default-ownership references work; a DataView (to-one) over the same reverse works | **Not an mxcli encoding bug** — the `Forms$AssociationSource` + `EntityRefStep{Assoc, DestinationEntity}` is correct (verified via `bson dump`). It's correct MxBuild behavior: `owner both` on a plain `type reference` makes the reverse navigation **to-one**, so a *list* widget over it is invalid (a DataView is fine). The single-ref-vs-multi-ref inconsistency (single owner-both allows the reverse listview; adding a sibling association flips it to-one) hints at a **separate, unfixed `owner both` domain-model serialization quirk** — not yet root-caused (needs a Studio-Pro owner-both reference for comparison) | domain-model writer (`sdk/mpr/writer_domainmodel.go` / `mdl/backend/modelsdk/domainmodel_write.go` association owner/navigability) — **investigate before adding any check** | **No check added** (deliberately — the condition isn't statically precise and isn't always-wrong; adding one would repeat the MDL-WIDGET08 over-rejection mistake). Guidance only: use `default` ownership for a normal to-one reference (reverse is then to-many, list widgets work); reserve `owner both` for `ReferenceSet`. Documented in `generate-domain-model.md` (Owner Options). Diagnose CE8812 by dumping the widget source (encoding is usually right) then checking the association's `owner` — bisect the domain model, not the page. **Method note:** bisected this with `mxcli docker check --no-update-widgets` over a fresh test-project copy per variant | -| A `dynamictext` contentparam (or DataGrid2 column) navigating an association still fails mxbuild **CE0402 "No value specified"** — the *actually unsolved* Bug 3 case. Reproduces only when the widget's entity context is a **specialization** of the entity that declares the association: a grid over `SpecialExpense extends Expense` with `contentparams: [{1} = Expense_Employee/Name]` (the `Expense_Employee` association is on the base `Expense`). An exact-endpoint context (grid over `Expense`) works. `describe` shows `[{1} = ]`; the binding is dropped. **Very common in real apps** (entities inherit from a base, associations on the base) — which is why the earlier fixes looked incomplete | `resolveAssociationAttributePath` → `associationDestination` matched the context against the association's FROM/TO **by exact string equality**. A subclass context equalled neither endpoint, so it returned `ok=false` and the caller fell back to the flat, unresolvable `Assoc/Attr` path → no valid AttributeRef → CE0402. Affects both engines (shared builder) and both the contentparam and Bug 7 column-attribute paths | `mdl/executor/cmd_pages_builder_v3.go` (`associationDestination`, new `entityIsOrDescendsFrom` + `entityGeneralizations`) | Match the endpoint the context **is or descends from**: walk the generalization chain (`Entity.GeneralizationRef`, qualified parent name) so an association declared on a base entity resolves from a subclass context. Diagnose association-binding drops by checking whether the widget's entity context exactly equals the association endpoint — inheritance is the usual culprit. mxbuild-verified 0 errors across grid-over-base, listview, and grid-over-subclass. Tests: `TestResolveAssociationAttributePath_InheritedContext`; bug-test `mdl-examples/bug-tests/bug3-contentparam-inherited-association.mdl`. Bug 3 (inheritance) | -| A documented object-list widget child keyword parse-errors: `line lRevenue (...)` inside a LineChart/TimeSeries/BubbleChart, or `scalecolor scLow (...)` inside a HeatMap → `mismatched input 'line' expecting '}'`. `series` (etc.) works | The executor side was already complete — `deriveObjectListKeyword` maps `lines`→`LINE` and `scaleColors`→`SCALECOLOR`, and `buildObjectListItem` routes any object-list child generically via the widget's `.def.json`. The **grammar** just lacked the tokens: `LINE`/`SCALECOLOR` weren't lexer tokens nor `widgetTypeV3` alternatives | `mdl/grammar/MDLLexer.g4` (token) + `mdl/grammar/domains/MDLPage.g4` (`widgetTypeV3`) + `mdl/grammar/domains/MDLSettings.g4` (`keyword` rule) | Add the token, the `widgetTypeV3` alternative (alongside `SERIES`/`MARKER`), **and** the `keyword` rule entry (so a very common word like `line` stays usable as an identifier via `identifierOrKeyword`); `make grammar`. No executor/def changes needed — `deriveObjectListKeyword` already produces the singular-uppercase keyword and the def.json carries the mapping. Verify child data lands in BSON (`Lines`/`Series` array). Confirmed via `mdl-examples/doctype-tests/34-chart-widget-examples.mdl` (exec 0 errors; `line`/`scalecolor` persist). DESCRIBE round-trip is handled by the row below | -| `DESCRIBE` of a generic pluggable widget (chart) omits its object-list child blocks — `series`/`line`/`scalecolor` items are dropped, so describe→exec loses the chart's data. (Pre-existing; SERIES always had it.) DataGrid2/Gallery were unaffected — they have specialized column output | The generic `pluggablewidget` DESCRIBE branch only emitted scalar `ExplicitProperties`; nothing walked the widget's `WidgetObject` lists. (The DataGrid2 column reconstruction, `extractDataGrid2Columns`, was the only object-list reader and is hard-coded to `columns`.) | `mdl/executor/cmd_pages_describe_objectlist.go` (`extractObjectLists`, `buildObjectListNestedKeyMap`, `extractObjectListItem`, `objectListMDLKey`) + wired into `cmd_pages_describe_parse.go` (generic-pluggable branch) + `cmd_pages_describe_output.go` (emit child blocks) | Generalize the column-reconstruction pattern: `buildObjectListNestedKeyMap` (like `buildColumnPropertyKeyMap` but for any list key) resolves the item's `TypePointer→sub-key` map from `Type.ObjectType.PropertyTypes[listKey].ValueType.ObjectType.PropertyTypes`; per item, read datasource / AttributeRef / Expression / TextTemplate / PrimitiveValue and map the schema key to PascalCase MDL (`staticXAttribute`→`StaticXAttribute`). Keyword via `deriveObjectListKeyword` (lowercased). Only runs for the generic branch (`!isKnownCustomWidgetType`), so DataGrid2/Gallery keep their specialized output — no double-emission. Verified: describe→exec→describe is byte-identical on bar/line/heatmap. Tests: `TestExtractObjectListItem_ChartSeries`, `TestObjectListMDLKey`. | -| A HeatMap `scalecolor` entry's `ColorValue: '#rrggbb'` parses and passes `check` but the colour is **silently dropped on write** — describe shows only `ValuePercentage`, and the runtime scale has no colour. `valuePercentage` persists fine | The schema property is spelled **`colour`** (British), and `scaleColors` had no `ColorValue` MDL alias, so the engine looked up `colour`, didn't find `ColorValue`, and wrote the template default (empty). Identical mechanism to Bug 10a (`columnClass`←`DynamicCellClass`) — a missing `itemPropertyAliases` entry | `mdl/executor/widget_defs.go` (`itemPropertyAliases`, `heatmap.scaleColors.colour`) + `mdl/executor/widget_engine.go` (`WidgetDefGeneratorVersion` bump) | Add `"colour": {"ColorValue"}` to the HeatMap `scaleColors` aliases and **bump `WidgetDefGeneratorVersion`** so cached `.mxcli/widgets/heatmap.def.json` regenerate. DESCRIBE reconstructs it as `Colour:` (the PascalCase schema key — round-trips case-insensitively). Verified: `#rrggbb` now in BSON; describe→exec byte-stable. Test: `TestObjectListItemAliases_HeatMapColour`. Bug 10a class | -| A **PieChart/HeatMap** (charts that bind data at the WIDGET level, no series object-list) fails MxBuild **after `mx update-widgets`** with **CE0642** "Value attribute is required" and (PieChart) **CE4899** "Series name is required" — even though `mxcli check` + `exec` are clean. The datasource persists; the value attribute + series name don't. Only surfaces once CE0463 version-drift is cleared by update-widgets | These widgets expose several attribute-typed top-level properties (`seriesValueAttribute`, `seriesSortAttribute`) plus a required `seriesName` texttemplate. The engine's `resolveMapping` `"Attribute"` case read a single generic `w.GetAttribute()` — ambiguous across multiple attribute props and blind to the friendly MDL names — so `ValueAttribute:` never reached `seriesValueAttribute`; and `GenerateDefJSON` **skips all top-level texttemplate props**, so `seriesName` had no mapping. Item 1b | `mdl/executor/widget_defs.go` (`propertyAliases`, `GenerateDefJSON` attribute-alias + gated texttemplate case) + `mdl/executor/widget_engine.go` (`PropertyMapping.MdlAliases`, `namedPropValue`, `resolveMapping` `"Attribute"`/`"TextTemplate"`, version bump) | Add `PropertyMapping.MdlAliases` (top-level analog of `ItemPropertyMapping.MdlAliases`) + a `propertyAliases` map (piechart/heatmap `seriesValueAttribute`←`ValueAttribute`, piechart `seriesName`←`SeriesName`). `resolveMapping` `"Attribute"` now reads via `namedPropValue` (property key + aliases) and resolves against the widget datasource entity (the DataSource mapping is ordered first), falling back to `w.GetAttribute()` for single-attribute widgets. Emit a top-level texttemplate mapping **only** when an alias is registered (keeps the broad skip). Bump `WidgetDefGeneratorVersion`; the example must supply the required `SeriesName:`. mxbuild-verified: whole chart file = 0 errors after update-widgets. **Diagnose "required property" CE0642/CE4899 by running `mx update-widgets` first (clears CE0463), then check which schema key is missing from the BSON.** Tests: `TestResolveMapping_NamedAttribute`, `TestGenerateDefJSON_PieChartNamedProperties`. Widget-level DESCRIBE of `seriesName`/datasource is a separate gap. Item 1b | -| A view entity whose attribute is named after a Mendix OQL keyword — most often a date-part word like `Quarter`/`Month`/`Year` — passes `mxcli check`/`exec` but fails **MxBuild CE0174** "The 'Quarter' part is incomplete or incorrect. You could use here: … OPEN_QUOTE, or IDENTIFIER". A `Region`/`Period` column is fine | OQL reads the bare word as the keyword, not the attribute. **This row previously said mxcli cannot escape it and that `s."Quarter"` is a parse error — both are wrong**, and the error text quoted in the row disproves the first (it lists `OPEN_QUOTE` among what is valid there) | `mdl/executor/oql_type_inference.go` (`ValidateOQLSyntax`, `oqlReservedWords`, **MDL032**, **MDL072**); `mdl/executor/validate_oql_reserved_names.go` (**MDL071**) | **Quote it in a SOURCE position** — `select s."Quarter" …`, `from Module."Year" as s` — measured at 0 errors on 11.13.0, with the quotes passed through verbatim (`RawQuery` via `extractOriginalText`). The **alias** is the one position OQL will not take a quote in, for any name at all, so a view entity's own attribute — whose name IS its alias — has to be renamed; that is the only case that does. Read the two CE0174 texts side by side before concluding anything about quoting: the source position lists `ASTERISK, AT_SIGN, OPEN_QUOTE, or IDENTIFIER` and the alias position lists only `IDENTIFIER`. MDL071 warns at CREATE (where a rename is still cheap), MDL032 inside a view's OQL, MDL072 on the quoted-alias spelling. | -| A view entity with a **derived string column** — `cast(x as string)`, a string-returning `CASE`, or a string expression — passes `mxcli check`/`exec` but fails **MxBuild CE6770** "View Entity is out of sync with the OQL Query" whenever the declared attribute length is anything other than **200** (e.g. `string(30)`, `string(50)`, unlimited). The OQL itself runs fine, so it looks like a serialization gap but is a **platform rule**: Mendix normalizes a derived string column to the default length String(200); a plain pass-through column (`c.Name as Name`) instead inherits its source length | Two parts. (1) The rule: only *derived* string columns are forced to 200 — a bare attribute ref infers `TypeUnknown` and is skipped, so any concretely-typed String the static inferrer sees is derived → 200. (2) The checker was **dead**: `inferTypeStatic`/`inferTypeFromExpression`/`inferCaseType` uppercased the expr (`upper := ToUpper(...)`) then compared against **lowercase** literals (`HasPrefix(upper, "cast(")`, `"count("`, `"case"`, `== "true"` …), so every prefix check failed and only `DATEPART(` (uppercase literal) ever matched — CAST/CASE/COUNT/SUM/AVG/MIN/MAX/LENGTH inference all returned Unknown. Sibling of Bug 9b's `extractSelectClause` case bug | `mdl/executor/oql_type_inference.go` (`inferTypeStatic` CAST/CASE + case-fixed prefixes, `castTargetType`, `derivedStringLength=200`, `ValidateOQLTypes` string-length normalize, `typesStrictlyCompatible` length compare, `inferCaseType`, `inferTypeFromExpression`) — **MDL031** | Infer `cast(expr AS string)` and string CASE as `String(200)`; in `ValidateOQLTypes` normalize any inferred `TypeString` length to 200 (derived); `typesStrictlyCompatible` compares String **length** (not just kind), so `string(30)` vs `String(200)` is flagged with `Fix: change to 'X: String(200)'`. **Fix the case-comparison bug** (lowercase→UPPERCASE literals) so the checker actually runs. **Guard against false positives**: `SUM(` over an un-inferable inner (a bare attribute ref → Unknown) returns **Unknown**, not a guessed Decimal — otherwise `sum(s.Units)` declared `integer` false-fires (caught on `34-chart-widget-examples.mdl`). mxbuild-confirmed on 11.6.6: `string(30)`→CE6770, `string(200)`→0 errors. Tests: `TestValidateOQLTypesDerivedString`, `TestValidateOQLTypesNoFalsePositive`; bug-test `mdl-examples/bug-tests/view-entity-derived-string-length.mdl` | -| A view entity with a **pass-through string column** — a bare source-attribute reference like `select c.Name as CategoryName` — declared with a length **different from the source attribute** (most often an unbounded `string` against a `string(100)` source) passes `mxcli check` but fails **MxBuild CE6770** "View Entity out of sync". This is the counterpart of the *derived*-column rule (which forces 200): a pass-through column inherits the source length **exactly** | The references-mode validator (`validateViewEntityTypes`) compared with `typesCompatible`, whose String rule only guards **truncation** (`declared.Length >= inferred.Length`), so an unbounded `string` (Length 0) or a wider `string(200)` against a `string(100)` source slipped through. Only *derived* columns (via `ValidateOQLTypes`/`typesStrictlyCompatible`, syntax mode) were length-exact | `mdl/executor/oql_type_inference.go` (`passthroughStringLengthMismatch`, called in `validateViewEntityTypes` before the generic `typesCompatible`) | For a pass-through column (`col.SourceAttr != ""`, set only for a bare `alias.attr` ref — aggregates use a throwaway col so it stays empty) require **exact** String length match; emit the source entity/attr + inherited length in the message. **References-mode only** (needs the domain model to resolve the source attribute's length), so the repro is not a `.fail.mdl` (the syntax-only `make check-mdl` harness can't see it). Test `TestPassthroughStringLengthMismatch`; example `mdl-examples/bug-tests/ledger-36-view-passthrough-length.mdl`; mxbuild-confirmed on 11.12.1 (`string`→CE6770, `string(100)`→clean). Ledger finding #36 | -| `DESCRIBE` of a view entity on the **default (modelsdk)** engine omits the `as (… OQL …)` clause entirely (legacy renders it) — the describe→exec round-trip silently drops the query, leaving a queryless view. Reproduces on any Mendix **11.x** project | On Mendix 11.0+ the OQL is stored **only** in the separate `DomainModels$ViewEntitySourceDocument`; the inline `OqlViewEntitySource.Oql` field was removed (see the writer's `if !pv.IsAtLeast(11,0)` gate in `serializeOqlViewEntitySource`). The modelsdk read (`entityFromGen`: `out.OqlQuery = src.Oql()`) only read the now-empty inline field and never followed the `SourceDocumentRef`. Legacy was correct — `loadViewEntityOqlQueries` in `sdk/mpr/reader_documents.go` already joins the source docs | `mdl/backend/modelsdk/domainmodel.go` (`populateViewEntityOql`, wired into `ListDomainModels` + `GetDomainModel`) | After building the domain models, list `ViewEntitySourceDocument` units (`mprread.ListUnitsWithContainer[*genDm.ViewEntitySourceDocument]`), map `moduleName.docName → Oql()`, and fill `OqlQuery` for any view entity with an empty `OqlQuery` + a `SourceDocumentRef` (mirrors the legacy join; guarded by a cheap pre-check so non-view projects pay nothing). When a view-entity read differs between engines on 11.x, suspect a field the platform moved out-of-line into a source document. Test: `TestGetDomainModel_ViewEntityOqlFromSourceDocument` (round-trips via both read paths) | -| An `actionbutton`/`linkbutton` `icon:` property is silently dropped on write (flagged `MDL-WIDGET07` "property `icon` … will be silently dropped") — no way to give a button an icon, so the button renders without one. `linkbutton` (Link render mode) itself already worked | The button builder read Caption/Action/ButtonStyle but never the `icon` property, and the `Forms$ActionButton` codec default nulls the `Icon` field. Nothing built the Mendix icon element | `sdk/pages/pages_widgets_action.go` (`Icon` struct + `IconTypeIconCollection`) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildButtonV3` reads `icon`) + `mdl/backend/modelsdk/widget_write.go` (`iconToGen`, wired into the `ActionButton` case) + describe (`cmd_pages_describe_parse.go` `extractIconRef`, `cmd_pages_describe_output.go` emit) + `validate_widgets.go` (`staticWidgetKnownProps` += `Icon`) | Build the modern **icon-collection** icon: `icon: 'Atlas_Core.Atlas_Filled.pencil'` → `Forms$IconCollectionIcon{Image: QN}` (verified against a Studio-Pro button — storage `$Type` is `Forms$`, not `Pages$`; no typed gen struct, so build it via `newElem`/`addStr`). `g.SetIcon(...)` overrides the null-Icon default. DESCRIBE reconstructs `Icon:` from the element's `Image`; add `Icon` to the known-props list so MDL-WIDGET07 stops warning. A non-existent icon name is (correctly) rejected by MxBuild with **CE1613** "selected custom icon … no longer exists". Tests: `TestBuildButtonV3_Icon`, `TestOutputWidgetMDLV3_ButtonIcon`; bug-test `mdl-examples/bug-tests/602-button-icon.mdl`. Issue #602 | -| `DESCRIBE WORKFLOW` on the **default (modelsdk)** engine renders some activities as non-round-trippable comments — `-- [Workflows$StartWorkflowActivity]`, `-- [Workflows$JumpToActivity]`, `-- [Workflows$WaitForTimerActivity]`, `-- [Workflows$WaitForNotificationActivity]` — so `describe → exec` drops those activities and you can't learn their syntax from an existing workflow. User tasks, decisions, splits, call-microflow/workflow round-trip fine (legacy round-trips all of them) | The describe **formatter** (`formatWorkflowActivities`) already has typed cases for jump/wait/start/end, but the modelsdk **reader** never produced them: these activities have no `genWf` struct (written via generic `newElem`), so `workflowActivityFromGen`'s type switch fell through to `GenericWorkflowActivity`, which the formatter renders as `-- [$Type]`. Legacy's `parser_workflow.go` had dedicated cases, so legacy was correct — modelsdk-only gap | `mdl/backend/modelsdk/workflow_read.go` (`workflowActivityFromGen` default → new `workflowSimpleActivityFromGen`) | Recognise the untyped simple activities by `el.TypeName()` and rebuild the semantic struct, reading fields from raw BSON via `genWf.RawFieldString` (`TargetActivity` for jump, `Delay` for the timer) — mirrors the legacy parser. Start/end become typed so the formatter **skips** them (implicit in MDL); jump/wait become executable statements. When an engine emits `-- [$Type]` in describe, the *formatter* usually already handles the type — the gap is the *reader* decoding it to `GenericWorkflowActivity`. Test: `TestWorkflowSimpleActivities_ReconstructedTyped`; round-trip validated describe→drop→exec→`docker check` = 0 workflow errors. Bug 11b (11a = the missing `write-workflows.md` authoring skill) | -| A built-in widget action (`actionbutton`/`container`/`linkbutton` `Action: microflow M.Flow(Param: $x)`) **silently drops the argument** on the **default (modelsdk)** engine — `describe` shows `Action: microflow M.Flow` (no args), the flow runs with no parameter, and a required-param flow never fires so the button/row no-ops at runtime. `mxcli check`/`docker check`/`docker build` all pass (0 errors) — silent break. Legacy persists it (the workaround) | `clientActionToGen`'s `MicroflowClientAction` case built the `Forms$MicroflowSettings` from the microflow **name only** (`microflowSettingsToGen(x.MicroflowName)`), never serializing `x.ParameterMappings` — the executor builds them correctly into the model, the writer threw them away. (NOT the same as `custom-widgets.md`'s "action mapping too complex for auto-mapping" — that's about *pluggable-widget action-typed properties* in def.json generation, a different path.) | `mdl/backend/modelsdk/widget_write.go` (`microflowSettingsToGen`) | Give `microflowSettingsToGen` the mappings param; emit one `genPg.MicroflowParameterMapping` each — `SetParameterQualifiedName(".")` (BY_NAME) + `SetExpression(variable-or-expression)` — mirroring the legacy `writer_widgets_action.go` BSON. The client-action caller passes `x.ParameterMappings`; the microflow-datasource callers pass `nil`. Verified: default-engine describe now keeps `(Param: $x)`; a type-correct page = 0 mxbuild errors (both engines give the *same* CE0117 on a type-mismatched arg — that's a model error, not the writer). Tests: `TestClientActionToGen_MicroflowParameterMappings`; bug-test `mdl-examples/bug-tests/bug1-widget-action-param-mapping.mdl`. Nanoflow widget actions were a separate gap (the whole `NanoflowClientAction` case was missing) — now fixed in Bug 2 below. Bug 1 | -| A microflow `declare $x Module.Entity [= $obj];` (object/entity-typed local variable) passes `mxcli check` but mxbuild rejects it with **CE0053** "Selected type is not allowed" (+ CE0038 "Value required", + CE7247 on a following `set`) — a silent trap. The declare-entity attribute qualifier is a red herring: the whole construct is invalid, bare or initialized | Mendix's Create Variable activity (what `declare` maps to) only holds **primitive** types; there is no object/list Create Variable form (same restriction as MDL040 for lists). Objects must come from a parameter, a `retrieve … limit 1`, a `create` object, or a loop iterator. The check was missing; the microflow parser records a bare `Module.X` declare type as the ambiguous `TypeEnumeration` (`EnumRef`, `ExplicitEnum=false`), an explicit `Enumeration(Module.X)` sets `ExplicitEnum=true` | `mdl/executor/validate_microflow.go` (`walkBody` `*ast.DeclareStmt` case, next to the MDL040 list check) | Add **MDL043**: flag `Type.Kind == TypeEntity \|\| (TypeEnumeration && EnumRef != nil && !ExplicitEnum)`; error points to parameter/retrieve/create/loop and notes "if it's an enum, write `Enumeration(...)`". Reliable connection-free (no backend) because bare object names resolve to a distinct AST shape from explicit enums. Also fix the skills that wrongly taught `declare $x Module.Entity;` / `declare $x as …` as valid (`write-microflows.md`, `cheatsheet-variables.md`, `cheatsheet-errors.md`, `check-syntax.md`, `patterns-crud.md`, `patterns-data-processing.md`, `business-events.md`, `resolve-forward-references.md`, `migrate-k2-nintex.md`, `README.md`). Test: `TestValidateMicroflow_DeclareObjectIsRejected`; bug-test `mdl-examples/bug-tests/declare-object-variable-rejected.fail.mdl`. mxbuild-confirmed (11.12.0) | -| No way to change an existing **enumeration value's caption** in place — `alter enumeration` had only `ADD`/`RENAME`/`DROP VALUE` + `SET COMMENT`; `RENAME VALUE X TO Y` changes the *name*, not the caption. The only route was drop + recreate, which fails while the enum is referenced by an attribute | Missing grammar action + AST op + executor case — a full-stack gap, not a code bug | `mdl/grammar/domains/MDLDomainModel.g4` (`alterEnumerationAction`) → `mdl/ast/ast_enumeration.go` (`AlterEnumOp`) → `mdl/visitor/visitor_enumeration.go` (`ExitAlterEnumerationAction`) → `mdl/executor/cmd_enumerations.go` (`execAlterEnumeration`) | Add `MODIFY VALUE IDENTIFIER CAPTION STRING_LITERAL` (reuses existing `MODIFY`/`CAPTION` tokens, so no lexer change); add `AlterEnumModifyCaption` op reusing the `Caption` field; executor finds the value by name and replaces only the `en_US` translation (preserves the value's ID + other locales). Re-captions in place, so it works while referenced (mxbuild 0 errors) — no drop needed. Value names in `alter` must be plain identifiers (a reserved-word-named value like `Created` can't be targeted — pre-existing, shared by ADD/RENAME/DROP). Tests: `TestAlterEnumeration_ModifyValueCaption` (visitor) + `TestAlterEnumeration_ModifyValueCaption_Mock` (executor); example in `01-domain-model-examples.mdl` | -| A page with a native **LISTVIEW** over a `database from` (XPath) datasource passes `mx check` but **crashes the browser client at runtime** and redirects to login: `TypeError: Cannot read properties of undefined (reading 'length')` at `processResult` → `retrieveByXPath`. Pluggable Gallery/DataGrid2 database sources are fine | The serialized `Forms$ListViewXPathSource` omitted the arrays the client reads `.length` of. **Codec (default):** `Forms$ListViewSearch` emitted without its `SearchRefs` list — the encoder drops an empty, never-`Set` PartList unless the `$Type` is in `RegisterTypeDefaults` (GridSortBar.SortItems was registered; ListViewSearch was not). **Legacy:** wrote a bogus `Forms$ListViewSort` + a `Paths` key (renamed `SearchRefs` in 7.11.0) and no `Forms$GridSortBar`. NOT a `SortItems` marker issue — an empty `[2]` compiles fine when search is off; the crash is the absent `SearchRefs`. Diagnose by building a Deploy target and reading the compiled `deployment/web/pages/.js` (the client model) + `mxcli bson dump --type page` on the source | `mdl/backend/modelsdk/widget_write.go` (`init` RegisterTypeDefaults) + `sdk/mpr/writer_widgets_display.go` (`serializeListViewDataSource`, `emptyListViewXPathSource`) | Codec: `RegisterTypeDefaults("Forms$ListViewSearch", {MandatoryLists: []string{"SearchRefs"}})` (emits empty marker-3 list). Legacy: emit `Forms$GridSortBar`/`SortItems` (mirror `SerializeCustomWidgetDataSource`) + `Forms$ListViewSearch`/`SearchRefs` + `ForceFullObjects`; drop the bogus `Sort`/`Paths`. Tests: `TestListViewSourceToGen_SearchRefsEmitted` (codec, encode-level), `TestSerializeListViewDataSource_Database` + `TestEmptyListViewXPathSource_Shape` (legacy). Repro `mdl-examples/bug-tests/listview-database-source-searchrefs.mdl` | -| Editing `themesource/**/main.scss` (or `theme/web/main.scss`) while `mxcli run --local` serves on `:8080` keeps showing **old styles** — reads exactly like a stale compiled-CSS cache. `rm -rf theme-cache/ .mendix-cache/ deployment/` "fixes" it only because a restart came with it | THREE distinct causes, none a CSS cache: (1) **no `--watch` = no watcher at all** — `mxbuild --serve` only rebuilds on a `/build` request (startup, or a watch tick), so a save changes nothing; (2) **stale process silently adopted** — a leftover serve/runtime on the ports answers the startup readiness probes (`waitReady`/`waitAdminReady` only check "port answers"), so a new run attaches to the OLD process and its own child is torn down by `defer`; a backgrounded `run --local` whose wrapping shell exited non-zero dies while its serve+runtime keep serving; (3) the theme source was **watched by nothing** — the `--watch` signal was model-only (`.mpr`+`mprcontents/`). The incremental theme step itself is FINE: one `/build` after an scss **content** edit does rewrite `theme-cache/web/theme.compiled.css` (verified), so there is no cache to clear | `cmd/mxcli/docker/runlocal.go` — `checkTargetPortsFree` (guard), `themeSourceMTime`/`sourceMTime` (watch signal), `watchAndApply` (generation log) | (2) Refuse to boot when `:8080/:8090/:6543` already answer, with an actionable message (never auto-kill — user's call). (3) Add `theme/`+`themesource/` scss/css/js to the `--watch` mtime-poll signal (`sourceMTime` = max(model, theme)); poll-based so it's container-safe (unlike the rollup chokidar/inotify web-client watcher). Log a build-generation counter (`build #2`) so "did it take?" is answerable. Docs: `docs-site/src/tools/run-local.md` + skill `run-local.md` — "SCSS needs a rebuild (`--watch` or clean restart), never a cache-clear; kill the old serve/runtime first". Tests `TestThemeSourceMTime_WatchesThemeAndThemesource`, `TestCheckTargetPortsFree` | -| Re-running a domain script that is 90% already-applied applies **none** of the remaining 10%: `alter entity … add attribute X` errors `attribute 'X' already exists` and, because `exec` halts on the first error, everything after it is skipped. No idempotent add/drop and no continue-past-errors | Two gaps: (1) `ADD ATTRIBUTE` / `DROP ATTRIBUTE` had no `IF NOT EXISTS` / `IF EXISTS` guard, so a re-apply was a hard error; (2) `ExecuteProgram` returns on the first statement error | grammar `mdl/grammar/domains/MDLDomainModel.g4` (`ifNotExists`/`ifExists`, `alterEntityAction`) + AST `mdl/ast/ast_entity.go` (`IfNotExists`/`IfExists`) + visitor `mdl/visitor/visitor_entity.go` (`ExitAlterEntityAction`) + executor `mdl/executor/cmd_entities.go` (add/drop guards) + `mdl/executor/executor.go` (`ExecuteProgramContinueOnError`) + `cmd/mxcli/cmd_exec.go` (`--continue-on-error`) | Add `IF NOT EXISTS`/`IF EXISTS` to the grammar (regen), carry the flag on the AST, and in the executor turn the already-exists / not-found error into a skip-with-notice when the guard is set. Separately add `mxcli exec --continue-on-error`: attempts every statement, prints each failure as `statement N: …`, exits non-zero if any failed (never masks a real error; `exit`/`quit` still stop the run). Bug-test `mdl-examples/bug-tests/f10-idempotent-alter-entity.mdl`. Findings #10 | -| `alter entity M.E add attribute X: autonumber;` (no seed) or `... add attribute Created: AutoCreatedDate;` (renamed AutoX) passes `mxcli check` "Check passed!" but fails the build (CE7247) / silently discards the name — while the SAME attribute in `create entity` is correctly flagged (MDL023 / MDL022) | The per-attribute checks (MDL021/022/023) only ran on `CreateEntityStmt`; the ALTER ENTITY ADD ATTRIBUTE path had no validation at all, so an attribute added later escaped every rule | `mdl/executor/cmd_enumerations.go` (`ValidateAlterEntity`, `validateEntityAttribute`) wired from `cmd/mxcli/cmd_check.go` + `cmd/mxcli/lsp_diagnostics.go` | Extract the CREATE loop body into `validateEntityAttribute(attr, persistent, entityName)`; add `ValidateAlterEntity(stmt)` that runs it on `AlterEntityAddAttribute`. The entity kind isn't known from ALTER, so the persistent-only MDL020 is skipped; the kind-independent MDL021/022/023 all run. Bug-test `mdl-examples/bug-tests/f6-autonumber-seed-alter.fail.mdl`. Findings #6 (alter path) | -| `create or modify entity M.E ( )` on an entity that already has more attributes **silently drops** every attribute not re-listed (36→2 attrs seen in practice), then widgets/microflows still bound to them fail the build with CE1613 — and the "already exists" error that leads users here recommends the destructive `create or modify` for a partial edit | `create or modify` rebuilds the entity from the statement alone and REPLACEs the stored one, so any omitted attribute is deleted with no warning; the `NewAlreadyExistsMsg` hint pointed at `create or modify` without distinguishing "replace whole" from "add one" | `mdl/executor/cmd_entities.go` (`droppedEntityMembers`, the warn block before `UpdateEntity`, and the `execCreateEntity` already-exists message) | Warn-only (non-blocking, the user asked to modify): before `UpdateEntity`, diff existing vs replacement members (`droppedEntityMembers` — named attrs case-insensitive + the four audit flags) and print what's dropped + point at `alter entity … add attribute` for incremental edits. Fix the already-exists message to recommend `alter entity` for a member change and reserve `create or modify` for a full replace. Bug-test `mdl-examples/bug-tests/f24-create-or-modify-dataloss.mdl`. Findings #24 | -| A microflow expression calls a function that doesn't exist (e.g. `randomInt(9)` — in some docs but not a Mendix built-in): parses, passes `mxcli check`, then fails the build with CE0117 "Error(s) in expression". Also: a Decimal-returning function (`random()`, `secondsBetween`, the duration `*Between` family) assigned to an Integer/Long variable fails CE0117, but `mxcli check` only caught bare `div` | (1) The func checker only validated arity for *known* functions; an unknown call name was ignored. (2) `checkNumericAssignment` used `SourceIsArithmeticDecimal`, which fires only on arithmetic roots, so a Decimal *function* result slipped through. Also the `*Between` duration funcs were mistyped Integer in `funcTable` | `mdl/exprcheck/unknown_funcs.go` (`UnknownFunctionCalls`, `SourceRejectedForIntegerTarget`, `nearestFunc`) + `mdl/exprcheck/func_checker.go` (`funcTable` between-date return kinds) + `mdl/executor/validate_microflow.go` (`checkExprFunctions` → MDL044; `checkNumericAssignment` now uses `SourceRejectedForIntegerTarget`) | Walk each expression for `CallExpr` names not in `funcTable` (which lists *every* built-in — a bare `name(...)` is always a built-in call) → **MDL044** with a Levenshtein/prefix "did you mean" hint. Extend the Decimal-into-Integer check to Decimal-returning non-rounding functions → **MDL041**. Correct `secondsBetween`/`minutesBetween`/`hoursBetween`/`daysBetween`/`weeksBetween` to Decimal (calendar variants stay Integer, millisecondsBetween stays Long). When adding a new Mendix built-in, add it to `funcTable` or MDL044 will false-positive. Bug-tests `f1-unknown-expression-function.fail.mdl`, `f2-decimal-func-into-integer.fail.mdl`. Findings #1, #2 | -| `index name on (cols)` inside `create entity` (or `alter entity add index name on (cols)`) fails with `extraneous input 'on' expecting '('` — the SQL-like form docs/users expect. The bare `index name (cols)` worked | `indexDefinition` had no `ON` token: `IDENTIFIER? LPAREN indexAttributeList RPAREN` | `mdl/grammar/domains/MDLDomainModel.g4` (`indexDefinition`) | Make `ON` optional: `IDENTIFIER? ON? LPAREN indexAttributeList RPAREN`, regen grammar. `buildIndex` reads columns from `IndexAttributeList` only, so ON can't be mistaken for a column and no visitor change is needed. Covers both CREATE (entityOption) and ALTER ADD INDEX (shared rule). Bug-test `mdl-examples/bug-tests/f4-entity-index-on.mdl`. Findings #4 | -| `retrieve … where [Seq = $Game/MoveSeq + 1]` fails with a bare `mismatched input '+' expecting ']'` — no hint that Mendix XPath can't compute values (this is a Mendix limitation, not an mxcli bug) | Mendix XPath constraints take a literal/token/variable/path on the value side, never an arithmetic expression; the parse error named the token but not the cause | `mdl/visitor/visitor.go` (`enhanceErrorMessage`, `looksLikeXPathArithmetic`/`xpathArithmeticRe`) | Do NOT add grammar support (mxbuild would still reject the XPath). Add an error hint keyed on `mismatched input '<+|*|div|mod>' expecting ']'` (`expecting ']'` only occurs inside a `[…]` constraint) explaining the limitation and the workaround: compute into a variable first, then compare. Also documented in `xpath-constraints.md`. Bug-test `mdl-examples/bug-tests/f8-xpath-arithmetic.fail.mdl`. Findings #8 | -| Design properties are written free-form: a `ColorPicker`/`ToggleButtonGroup` value serializes as a plain option (wrong `$Type` for Studio Pro's Appearance tab), and a typo'd key/value (they're case-sensitive) passes `mxcli check`. Also `show design properties ` reports "No design properties found for widget type container" for a valid widget | Root bug: `resolveDesignPropsKey` upper-cased the MDL keyword but the lookup map is **lowercase-keyed**, so `container`→`DivContainer` never resolved — leaving `resolveDesignPropertyValueType` dead code and the theme registry unused on the write/validate paths | `mdl/executor/theme_reader.go` (`resolveDesignPropsKey` case fix) + `mdl/executor/cmd_pages_builder_v3.go` (`astDesignPropToValue` takes theme props) + `mdl/executor/validate_design_properties.go` (new, MDL-WIDGET11/12) wired from `cmd/mxcli/cmd_check.go` + `cmd/mxcli/lsp_diagnostics.go` (cached `themeRegistry`) | Fix `resolveDesignPropsKey` to lower-case the lookup. On write, resolve each flat value's type from the registry **by matching the value against the property's declared options** (see the CE6084 correction below — the control type alone does NOT decide it). On check (`-p` only, when themesource defines properties), walk page/snippet/alter-page widget trees and warn: **MDL-WIDGET11** unknown key (case-sensitivity hint / valid-key list), **MDL-WIDGET12** invalid value (lists allowed values). Warnings, not errors — a newer theme may add keys/values (forward-compat, per `page-styling-support.md:402`). Skip compound (registry doesn't model sub-props) and widgets with no type-specific metadata (pluggable). Bug-test `mdl-examples/bug-tests/typed-design-properties.mdl` | -| Follow-up regression from the row above: after typed design properties merged, `mx check` fails **CE6084** "Expected design property _Flex container_ / _Column gap_ / _Align items Y_ … to be of type **Toggle button group**, but found **Custom**" on any page using a flat `ToggleButtonGroup` value (Atlas flex/spacing/typography, e.g. `'Column gap': 'Medium'`). Broke `TestMxCheck_DoctypeScripts` on `12-styling`, `15c-fragment-bindings`, `31-pluggable-datagrid-gallery-v010` (both engines) — green on unit tests, red only in `make test-integration` | `resolveDesignPropertyValueType` mapped `ToggleButtonGroup`→`custom` by control type. But a ToggleButtonGroup selection picks one of a **fixed option set**, so Studio Pro stores it as an **Option** — a `Custom` value type mismatches the declaration. Only a ColorPicker's **off-list** value (a free-form hex) is genuinely Custom. The value type is decided by the **value**, not the control | `mdl/executor/cmd_pages_builder_v3.go` (`resolveDesignPropertyValueType`, now takes the value and reuses `themeOptionAllowed`) | Make it value-aware: value ∈ declared options → `option` (Dropdown, ToggleButtonGroup, predefined ColorPicker swatch alike); off-list **and** `ColorPicker` → `custom`; else `option`; no metadata → `option`. Verified: the three doctype examples pass `mx check` = 0 errors on both engines. Test `TestAstDesignPropToValue_Typed` extended with the `Column gap: Medium` + ColorPicker swatch/hex cases. **Diagnosis pattern**: a value-type/BSON-`$Type` mapping keyed on a *declared control type* is a trap — verify it against `mx check`, never assert it from the type name alone (this is exactly how the original bug slipped in). **Process lesson**: this shipped red because `make test-integration` (mx-check doctype roundtrips) was not run before merge — run it, not just unit tests, for any page/widget-serialization change | -| The **nightly** matrix (Mendix 10.24 / 11.6 / 11.12) fails only on **10.24**: `TestMxCheck_DoctypeScripts/15c-fragment-bindings-examples` → `Execution error: failed to build page: building block not found: Atlas_Web_Content.List_Cards` (both engines). 11.6/11.12 pass; unit tests + push-test (single-version) pass. Looks like a "design property on 10.24" issue but isn't | `15c` demonstrates `use building block Atlas_Web_Content.List_Cards`, an Atlas UI building block that ships in **11.x but is absent from the 10.x Atlas** (the example comment wrongly said "present in every standard Mendix app"). The example had **no `-- @version:` gate**, so on a 10.24 project the whole file ran and mxbuild couldn't resolve the block. (12-styling gates its design-property section at line 186; 31 gates the whole file at line 1 — both already skip on 10.24) | `mdl-examples/doctype-tests/15c-fragment-bindings-examples.mdl` | Add `-- @version: 11.0+` immediately before the `create page … P002_Rebound_Block` block (its last statement) so `filterByVersion` skips only the building-block demo on 10.x; the fragment-binding statements above stay ungated and keep 10.24 coverage. Verified: 15c passes on **10.24** (10 lines skipped, 0 errors) and still runs+passes the section on **11.6.3** (0 errors). **Diagnosis pattern**: a nightly-only, version-specific doctype failure = an example using a construct (building block, widget, syntax) that doesn't exist in the oldest matrix version and lacks a `-- @version:` gate; reproduce locally with `MX_BINARY=~/.mxcli/mxbuild//modeler/mx go test -tags integration -run TestMxCheck_DoctypeScripts/` | -| Same nightly pattern, **CE6083** this time: `TestMxCheck_DoctypeScripts/15b-fragment-slots-examples` fails only on **10.24** (both engines) — `[CE6083] "Design property Card style is not supported by your theme"` at every `cardWrap` container. `Card style` is an **Atlas v3 design property (11.x); the 10.x Atlas theme doesn't define it** | The shared `define fragment Card` used `designproperties: ['Card style': on]` and is instantiated by every page in the file, so a `-- @version:` gate would have to gate the whole file (killing 10.24 coverage of the slot feature the example is actually about). Unlike 15c's building block, the design property was **incidental** to the example | `mdl-examples/doctype-tests/15b-fragment-slots-examples.mdl` | **Drop the incidental v3 design property**, keep `class: 'card'` (Atlas card styling works on every version) — the example demonstrates content *slots*, not design properties (those live in 12-styling, gated 11.0+). Verified: 15b passes on 10.24 **and** 11.6.3 (0 errors, both engines). **Gate vs remove rule**: if the version-specific construct IS the point of a self-contained section → `-- @version:` gate it (15c); if it's incidental and in a shared/expanded definition → remove it and use a cross-version equivalent (15b). **Proactive sweep** after any such fix: `grep -lE 'designproperties|use building block' mdl-examples/doctype-tests/*.mdl` and confirm each usage is either gated or version-safe (note the doctype test skips `*.test.mdl`/`*.tests.mdl`) | -| `mxcli run --local`: when a page action throws, the browser shows the generic Mendix error dialog and there is nothing to correlate it against — the runtime's own stdout/stderr (server stack trace, microflow `LOG` output) is swallowed, so a server-side bug can't be told apart from a client one | The runtime JVM was spawned with `cmd.Stdout=log; cmd.Stderr=log` where `log` is an in-memory `syncBuffer` surfaced only on a *startup* failure; during normal operation it goes nowhere on disk | `cmd/mxcli/docker/localboot.go` (`spawnAndConfigure`, `openRuntimeLog`, `LocalRuntime.logFile`, `LocalRuntimeOptions.RuntimeLogPath`) + `cmd/mxcli/docker/runlocal.go` (default `/.mxcli/runtime.log`) + `cmd/mxcli/cmd_run.go` (`--runtime-log`) | Tee the JVM's stdout+stderr to `/.mxcli/runtime.log` via `io.MultiWriter(log, file)` (the in-memory buffer still backs startup-error reporting). Append across restarts with a `=== runtime start … ===` marker; close the handle on Stop/reopen. Default on; `--runtime-log ` relocates, `-` disables. Print the path at boot. Test `TestOpenRuntimeLog`. Findings #25 | -| Follow-up to the above (#25 re-test): `run --local` writes `runtime.log` but it stays **nearly empty** — the JVM tee captures startup/JVM output only; **application** logs (microflow `LOG`, server-side exception stack traces) never reach stdout, so a page-action error still can't be diagnosed | A standalone runtime (launched via `runtimelauncher.jar`) attaches **no log subscriber** by default — unlike a Studio Pro / m2ee run, which calls `create_log_subscriber` **after** start. Mendix application logs flow to log *subscribers*, not stdout, so with none attached they go nowhere | `cmd/mxcli/docker/runtime_controller.go` (`RuntimeController.LogSubscriberFile`/`Stdout`, `attachFileLogSubscriber`, called at the end of `Start`) + `cmd/mxcli/docker/localboot.go` (`StartLocalRuntime` sets `ctrl.LogSubscriberFile` to the abs runtime-log path) | After a successful `start` (and on every restart's `Start`, since each fresh JVM has no subscriber), call the `create_log_subscriber` admin action with `{type:"file", name:"mxcli-run-local", autosubscribe:"INFO", filename:, max_size:1GiB, max_rotate:0}`. **`max_rotate:0` is load-bearing**: the JVM stdout tee holds an fd on the same file, and a rotate-rename would detach it. Best-effort (a logging failure must not fail an up runtime — warn to Stdout instead). Pass an **absolute** path (the runtime's cwd is `/runtime`, not mxcli's). Tests `TestStart_AttachesLogSubscriber`, `TestStart_NoLogSubscriberWhenUnset`, `TestStart_LogSubscriberFailureNonFatal`. Findings #25 (round 2) | -| `create or modify persistent entity` (even a byte-for-byte identical re-run) NULLs every column value on the next runtime DB sync — rows survive, values gone. Reports success (`Modified entity`), `mx check` = 0 errors; the loss only surfaces when something reads the data. Diagnose via the DB-sync count in `runtime.log` ("Executing N database synchronization command(s)") on an unchanged model | `execCreateEntity` minted a FRESH attribute ID for EVERY attribute each run, even unchanged ones. Mendix's DB synchronizer keys off attribute identity — a new ID reads as "attribute departed + new attribute added", so it drops and re-adds the column. Only the entity's own ID was preserved; ALTER ENTITY (the safe path) mutates loaded attributes in place, keeping IDs | `mdl/executor/cmd_entities.go` (`execCreateEntity`) | On CREATE OR MODIFY of an existing entity, build a name→ID map from `existingEntity.Attributes` and reuse the existing ID for retained attributes (only new attributes get a fresh ID); `attrNameToID` propagates the reused ID into validation rules + indexes. Also steer the "already exists in project" hint (`validate_duplicates.go`) toward `alter entity … add attribute` for entities. Test `TestCreateOrModifyEntity_PreservesAttributeIDs`; repro `mdl-examples/bug-tests/create-or-modify-preserves-attribute-ids.mdl`. Findings #13 | -| `alter page P { set class = 'x' on }` (lowercase property) hard-errors on EVERY built-in widget: `property "class" not found (widget has no pluggable Object)` — but `create page` accepts the same lowercase prop, and these aren't pluggable widgets | The MPR page mutator dispatched first-class props via a case-SENSITIVE switch on canonical casing ("Class"/"Caption"/"DynamicClasses"); a lowercase name missed every case and fell through to the pluggable-Object path. `create page` was fine (WidgetV3.GetStringProp is case-insensitive); the sibling MCP mutator already lowercased | `mdl/backend/pagemutator/mutator.go` (`setRawWidgetPropertyMut`) | `switch strings.ToLower(propName)` with lowercase case labels; keep the pluggable fallback (default) on the original casing (template keys are case-sensitive). Test `TestSetWidgetProperty_LowercaseFirstClassProps`; repro `mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl`. Findings #1 | -| `call javascript action` (e.g. NanoflowCommons.OpenURL) passes `check --references` + `exec` but persists as `-- Empty action`; `mx check` fails CE0008 "No action defined." + CE0109 on the output variable. Affects microflows AND nanoflows | The default `modelsdk` engine's `microflowActionToGen` had no case for `*microflows.JavaScriptActionCallAction` → returned nil → ActionActivity written with no Action. Read path + legacy engine both handled it (hence check/exec looked green); nanoflows reuse the same converter | `mdl/backend/modelsdk/microflow_write.go` (`microflowActionToGen`) | Add the `JavaScriptActionCallAction` case, built directly to mirror the legacy serializer (storage keys JavaScriptAction/OutputVariableName; JS parameter mappings use the `ParameterValue` key, not the Java-style `Value`). Test `TestMicroflowActionToGen_JavaScriptActionCall`; repro `mdl-examples/bug-tests/javascript-action-call-persist.mdl`. Findings #11 | -| A widget EXPRESSION property (`dynamicclasses`/`visibleif`/`editableif`) that walks an association passes `mxcli check --references` but fails `mx check` with CE0117 "Error(s) in expression." Easy to trip: a data binding (`contentparams`) on the SAME widget can traverse the same association legitimately | No check inspected expression-typed widget property values for association steps (MDL-WIDGET04/07/etc. check placeholders/keys, not expression contents). Mendix client-side expressions cannot follow associations — only data bindings can | `mdl/executor/validate_widgets.go` (`validateWidgetExpressionAssociations`, called from `validateStaticWidget`) | New MDL-WIDGET13: for `DynamicClasses`/`VisibleIf`/`EditableIf`, regex `exprAssociationStepRe` (`/Ident.Ident/`) flags a module-qualified step between slashes; a plain attribute (`$obj/Slug`) or enum literal (`Mod.Enum.Val`, no leading slash) doesn't match. Fix for the user: denormalise the attribute onto the bound entity (calculated attribute) or use a data binding. Test `TestValidateWidgetExpressionAssociations`. Findings #4 | -| Checker HINTS send the author the wrong way: MDL001 (nested loop) recommends `retrieve $Match from $List where … limit 1` (a parse error — can't filter a list variable); MDL044 flags `count()` in an expression with "Did you mean 'round()'?" (count is an aggregate, not a typo); MDL044's hint cites `mxcli syntax expressions` (no such topic) | Message-only defects in the linter | `mdl/executor/validate_microflow.go` (MDL001 message ~216; `checkExprFunctions` ~283; `mendixAggregateFuncs`) | MDL001 → recommend `$Match = FIND($List, )` (in-memory O(N); also fix CLAUDE.md idiom #2); MDL044 → for `count/sum/average/minimum/maximum` emit "aggregate activity, not an expression function — assign to a variable first: `$n = count($List);`" instead of a did-you-mean; point the generic hint at `mxcli syntax microflow`. Findings #7/#8/#14c | -| A microflow-CALL output variable reused across a fallback chain (`$S = call M.Inner(...); if … then $S = call M.Inner(...) end if;`) — the natural "try A else try B" — fails the build CE0111 "Duplicate variable name" | Each `$Var = call microflow/create/retrieve …` is a *fresh* variable creation; reusing the name (even inside an if) is a duplicate. `check --references` runs the flowBuilder validation (`validateOutputVariable`) which catches it — bare `check` (no project) does not | `mdl/executor/cmd_microflows_builder_validate.go` (`validateOutputVariable`, `validateScopedStatements`) | Already caught by the shared create-output-var check (same fix as the retrieve/create case); locked in by `TestValidateDuplicateMicroflowCallOutputVar` (same-scope + nested-in-if). Fix for the user: one variable per call, then a plain `set` picks the winner (documented in write-microflows.md). Finding #5 (2nd trigger) | -| `textbox` silently drops `placeholder` and `onchange` (MDL-WIDGET07 warns "unrecognized property … dropped"); after `create page` + `DESCRIBE`, both are gone — the live-search box degrades to a button, the field loses its hint text | The `pages.TextBox` model already had `Placeholder`/`OnChangeAction` fields, and the modelsdk (default) engine's `widgetToGen` already serialized them — but (a) the grammar had no `onchange:`/`placeholder:` property, (b) `buildTextBoxV3` never populated the model, (c) the legacy `serializeTextBox` hardcoded empty, (d) they weren't in `staticWidgetKnownProps` | grammar `mdl/grammar/domains/MDLPage.g4` (`widgetPropertyV3`); visitor `mdl/visitor/visitor_page_v3.go`; `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildTextBoxV3`); `sdk/mpr/writer_widgets_input.go` (`serializeTextBox`) + `writer_widgets.go` (`serializePlaceholderTemplate`); `validate_widgets.go` known-props | Add `ONCHANGE COLON actionExprV3` + `PLACEHOLDER COLON STRING_LITERAL` (tokens already existed); visitor → `Properties["OnChange"]`/`["Placeholder"]`; `GetOnChange()`/`GetPlaceholder()` accessors; builder sets `tb.Placeholder` (a `*model.Text`) + `tb.OnChangeAction` (via `buildClientActionV3`, reusing the button path); legacy serializer emits `serializePlaceholderTemplate(tb.Placeholder)` + `serializeClientAction(tb.OnChangeAction)`; add `OnChange`/`Placeholder` to `staticWidgetKnownProps`. Tests `TestSerializeTextBox_PlaceholderAndOnChange`; repro `mdl-examples/bug-tests/textbox-placeholder-onchange.mdl`. Finding #9 | -| `call javascript action` / `call java action` with a wrong-cased or misspelled PARAMETER name passes `check --references` (the action itself resolves) but writes a dangling reference that fails the build with CE1613 "The selected … parameter … no longer exists". E.g. `NanoflowCommons.OpenURL(url = …)` when the parameter is `Url` | The reference checker resolved only the action NAME (`buildJava*ActionQualifiedNames` returns names, discarding params); the call's `CallArgument` names were never compared to the action's declared parameters | `mdl/executor/validate.go` (`flowRefCollector` → `codeActionCallRef`, `validateCodeActionParams`) | Carry the call's `argNames` on the collector; after the name-exists check, `ReadJavaScriptActionByName`/`ReadJavaActionByName` → `.Parameters[].Name`, diff case-sensitively; casing-only mismatch → did-you-mean. Skip `System.*` (runtime-provided) and degrade to no-error when the backend reports no params. References-mode only (needs `-p`). Tests `TestValidateCodeActionParams`. RSS-reader follow-up finding | -| `DESCRIBE PAGE` omits a textbox's `placeholder`/`onchange` even though they are written correctly (present in the .mxunit, live app works) — so a DESCRIBE round-trip is not a reliable way to confirm the write landed | The describe read path (`parseRawWidget`) only read `LabelTemplate` + `AttributeRef` for a textbox; `PlaceholderTemplate` and `OnChangeAction` were never read back into `rawWidget` | `mdl/executor/cmd_pages_describe_parse.go` (`extractPlaceholderText`), `cmd_pages_describe.go` (`rawWidget` fields), `cmd_pages_describe_output.go` (`renderClientActionMDL`/`extractOnChangeAction` + TextBox emit) | Add `Placeholder`/`OnChange` to `rawWidget`; read `PlaceholderTemplate` via `extractTextFromTemplate` (same as label) and `OnChangeAction` via a key-agnostic `renderClientActionMDL` (refactored out of `extractButtonAction`, since OnChangeAction is the same client-action type under a different key); emit `Placeholder:`/`OnChange:` in the TextBox case. Single describe path serves both engines (reads raw BSON via `GetRawUnit`). Test `TestParseRawWidget_TextBoxPlaceholderAndOnChange`. RSS-reader follow-up (verification note on #9) | -| `DESCRIBE PAGE` of a `dynamictext` bound to a NON-STRING attribute (Integer/DateTime/…) emits `ContentParams: [{1} = toString($currentObject/Attr)]`; re-applying that output fails the build with CE1613 "attribute '…toString($currentObject/Attr)' no longer exists" — the rendered expression is treated as an attribute NAME. A string binding round-trips fine (bare attribute name) | The write side converts a non-String attribute binding to `toString($currentObject/Attr)` (`resolveTemplateAttributePathFull`), stored as a ClientTemplateParameter `Expression`; the describe reader emitted the Expression verbatim instead of reversing the transform | `mdl/executor/cmd_pages_describe_output.go` (`unwrapToStringAttrParam` in `extractClientTemplateParameters`) | When a ContentParam Expression is exactly `toString($currentObject/)` or `toString($param/)` (the auto-generated forms), emit the bare `` / `$param.attr`; the write side re-derives the toString on the next apply, so it round-trips. Any other expression (extra text, hand-written toString) is left untouched. Tests `TestUnwrapToStringAttrParam`, `TestParseRawWidget_DynamicTextNonStringAttribute`. RSS-reader follow-up finding | -| A `contentparams`/`captionparams` value bound to a client EXPRESSION (`[{1} = formatDateTime($obj/LastImport, 'd MMM yyyy')]`) passes `mxcli check` but Studio Pro rejects the page with CE1613 "attribute … no longer exists" — the whole expression is stored as a bogus attribute name. A plain attribute path (`$obj/Attr`) or quoted literal (`'text'`) works | **mxcli** treats a template-parameter slot as a data binding: `buildClientTemplateParams` stores any unquoted value as an attribute path (`resolveTemplateAttributePathFull`), so an expression becomes a bogus attribute name. No check inspected the value for expression syntax. NOTE the original row said a template parameter *is* a data binding "not an expression" — that is a claim about MENDIX and it is **false**: Studio Pro's Edit Template Parameter dialog offers `Parameter type: Value | Expression`, with its own editor, variable list and wizard, and `Pages$ClientTemplateParameter` carries `Expression` beside `AttributeRef` and `SourceVariable`. The limit is mxcli's | `mdl/executor/validate_widgets.go` (`validateTemplateParamExpressions`, called from `validateStaticWidget`) | New **MDL-WIDGET14**: for each `ContentParams`/`CaptionParams` value, skip quoted string literals, then regex `templateParamExprRe` (`Ident(` function call or an arithmetic/comparison operator) flags an expression; an attribute path never matches. Fix for the user: set the parameter's type to Expression in Studio Pro, or bind an attribute path / quoted literal in MDL — do NOT send them to build a calculated attribute for something the platform already offers (the message used to, which is unnecessary modelling). Test `TestValidateTemplateParamExpressions`; bug-test `mdl-examples/bug-tests/contentparam-expression-rejected.fail.mdl`. Ledger finding #26 | -| A microflow expression using `dateTime(2026, $Month, $Day)` (a variable/computed arg) passes `mxcli check` but fails the build with CE0117 — Mendix builds `dateTime()`/`dateTimeUTC()` from hardcoded numeric constants only | No check inspected date-construction arguments; the function name resolves so the unknown-function check (MDL044) is silent | `mdl/executor/validate_microflow.go` (`checkDateTimeLiterals`/`exprHasNonLiteralDateTime`, MDL046) | New **MDL046**: walk the expression for a `FunctionCallExpr` named `dateTime`/`dateTimeUTC` with any non-`LiteralExpr` argument. Fix for the user: step off a literal anchor date — `addDays(addMonths(dateTime(2026,1,1), $Month-1), $Day-1)` (addDays/addMonths take variables). Test `TestValidateMicroflow_DateTimeLiterals`; repro `mdl-examples/bug-tests/ledger-21-datetime-literals.fail.mdl`. Ledger finding #21 | -| A retrieve constraint `where [Ledger.Transaction_Category = empty]` (an ASSOCIATION `= empty`) passes `mxcli check` but fails the build with CE0161 "Error(s) in XPath constraint" — XPath `= empty` tests attribute nullability, not association nullability | No check inspected constraint strings for an association compared to `empty`; a bare attribute `= empty` IS valid, so a blanket ban would false-positive | `mdl/executor/validate_microflow.go` (`checkXPathAssociationEmpty`/`xpathAssocEmptyRe`, MDL047) | New **MDL047**: on a `RetrieveStmt.Where` (via `expressionToXPath`), regex a **module-qualified** name (one dot) directly `= empty`, with a leading boundary class that excludes `/` (so `Assoc/Attr = empty` — a valid attribute-over-association test — is NOT flagged) and `.`/word (so a 3-part enum literal tail isn't grabbed). Fix for the user: `[not(Assoc/Module.Target)]`. **Also covers page/widget datasource where-clauses** (`validateDatasourceXPathAssociationEmpty` on `DataSourceV3.Where`, shared regex via `xpathAssociationEmptyMatches`) — the ledger project hit it there first. Tests `TestValidateMicroflow_XPathAssociationEmpty`, `TestValidateDatasourceXPathAssociationEmpty`; repros `ledger-25-xpath-assoc-empty.fail.mdl` (retrieve) + `ledger-25-page-datasource-assoc-empty.fail.mdl` (datagrid). Ledger finding #25 | -| Two sibling `dynamictext` widgets in a container render as `€ 310Last import: 7/24/2026` — concatenated with no separator, regardless of each one's RenderMode | A Mendix DynamicText is always an inline ``; RenderMode does not make it block-level. Not a build error — a silent layout surprise, so a non-fatal advisory fits | `mdl/executor/validate_widgets.go` (`validateConsecutiveDynamicText`, called from `validateWidgetTreeIn`) | New **MDL-WIDGET15** (info): scan each sibling list; emit once when a run of ≥2 adjacent **inline** dynamictexts occurs. **Only H1–H6 are block-level** (`headingRenderModeRe`) — `Text`/unset AND `Paragraph` both render as an inline `` (verified on Mendix 11.12.1 + Atlas), so `inlineDynamicText` excludes only headings. A heading+subtitle pair is NOT flagged; a Paragraph+Paragraph pair IS (it fuses). Fix for the user: merge into one dynamictext, wrap each in a container, or use a **heading** RenderMode — NOT Paragraph. Info severity so it never fails the build. Test `TestValidateConsecutiveDynamicText`; repro `mdl-examples/bug-tests/ledger-27-consecutive-dynamictext.mdl`. **Lesson: verify Mendix render behavior empirically — `Paragraph` sounds block-level but isn't; don't infer `display` from a name.** Ledger findings #27/#29 | -| `create association … from/to ` passes `mxcli check --references` and `mxcli exec` creates it, but `mx check` rejects it with **CE6771** "It is not possible to create associations to/from View Entities." Both directions are invalid | Associations to/from a view entity are statically impossible, but the reference checker only validated that the endpoint modules exist — it never inspected whether an endpoint was a view entity | `mdl/executor/validate.go` (CreateAssociationStmt case) + `mdl/executor/cmd_entities.go` (`isViewEntity`) | Resolve both endpoints via `findEntity`; if either `isViewEntity` (Source `DomainModels$OqlViewEntitySource`, or OqlQuery/SourceDocumentRef set), reject with CE6771 and point at a non-persistent entity carrying a real reference. Same-script endpoints are skipped (validated on their own statement). **References-mode only** (needs the domain model). Tests `TestIsViewEntity`; example `mdl-examples/bug-tests/ledger-41-view-entity-association.mdl`. Verified on Mendix 11.12.1. Ledger finding #41 | -| A `create or modify entity` that omits (drops) an INDEXED attribute leaves the entity's index orphaned — its column references a GUID that no longer exists. `mxcli` prints only the attribute-drop warning; `mx check` then **CRASHES loading the project** with `System.AggregateException` "The given key '' was not present in the dictionary" (DESCRIBE shows a dangling `index ()`) | Two parts. (1) The executor rebuilt the entity from the statement (no index clause → empty `entity.Indexes`), so the existing index wasn't reconciled against the surviving attributes. (2) The write path: `entityToGen` produced an **empty, untouched** Indexes PartList, which the codec treats as "clean" and passes the raw (orphaned) index through — an empty typed list does NOT override raw bytes for an existing element | `mdl/executor/cmd_entities.go` (`reconcileDroppedIndexes`, before `UpdateEntity`) + `mdl/backend/modelsdk/domainmodel_alter.go` (`UpdateEntity` empty-index dirtying) | (1) When the statement lists no indexes, carry existing indexes forward, pruning columns for dropped attributes (attr IDs survive by name via #13) and dropping empty indexes — a **partial** drop (`index (A,B)` → drop B → `index (A)`) works via this non-empty list. (2) For the **all-removed** case, force the empty Indexes list dirty (`AddIndexes(NewIndex())` + `RemoveIndexes(0)` — `PartList.Remove` calls `markDirty`) so the codec re-emits it as empty, clearing the raw orphan. **Codec insight: an untouched empty PartList is `clean` → raw passthrough; only a *dirtied* list (even if empty) overrides raw for an existing element.** Tests `TestReconcileDroppedIndexes`; example `mdl-examples/bug-tests/ledger-39-drop-indexed-attribute.mdl`. **Verified end-to-end: `mx check` → 0 errors on Mendix 11.12.1** (previously crashed). Ledger finding #39 | -| `retrieve … where [id = $Var]` (constraining on the object id) passes `mxcli check` but fails the build with **CE0161** "Error(s) in XPath constraint" — whether `$Var` is String or Long. Mendix XPath has no id operator reachable from a microflow expression | No check inspected retrieve constraints for an id comparison; `id` is a reserved member so it resolves loosely | `mdl/executor/validate_microflow.go` (`checkXPathIdConstraint`/`xpathIdConstraintRe`, MDL048) | New **MDL048**: on `RetrieveStmt.Where`, regex a bare `id` (word-boundaried, case-insensitive) before a comparison, **capturing the operand**, and flag only when the operand is a VALUE — a `$`-var whose kind is a primitive (in `varKinds`; objects aren't) or a string/number literal. **Comparing `id` to an OBJECT variable (`[id != $obj]`, the valid "exclude self" pattern) is NOT flagged** — verified valid on mx check; an over-broad `\bid\b\s*[=…]` regex false-positived `16-xpath-examples.mdl`'s exclude-self example. Fix for the user: a marketplace GUID action (GetObjectByGuid), **or** expose the id as a String on a view entity (`cast(id as string) as ObjectId`) and constrain on that String column. Test `TestValidateMicroflow_XPathIdConstraint`; repro `mdl-examples/bug-tests/ledger-42-retrieve-by-id.fail.mdl`. **Lesson: `[id = $x]` splits on the operand type — value → CE0161, object → valid; a checker that ignores the operand mislabels the valid case.** Ledger finding #42 | -| A call argument bound to an association-object path (`call M.Consume(B = $Edit/M.Edit_Budget)` — the object reached over an association) passes `mxcli check` but fails the build with **CE0117** "Error(s) in expression." An attribute value over the same association (`$Edit/M.Edit_Budget/Name`) is fine | Mendix does not treat an association path as a value — it must be materialized (`retrieve`) first. No check inspected call arguments for an association-object path | `mdl/executor/validate_microflow.go` (`checkAssociationObjectArgs`/`exprIsAssociationObjectPath`, MDL049; wired for CallMicroflowStmt + CallNanoflowStmt) | New **MDL049**: an argument whose value is an `AttributePathExpr` whose FINAL segment is module-qualified (a `.` → an association, yielding an object) is flagged; a final bare segment (an attribute) is not. Fix for the user: `retrieve $x from $Edit/M.Edit_Budget;` then pass `$x`. Test `TestValidateMicroflow_AssociationObjectArg`; repro `mdl-examples/bug-tests/ledger-44-assoc-path-as-value.fail.mdl`. Ledger findings #43/#44 | -| ~~MDL050 "format function + association navigation"~~ **REMOVED — it was a false positive.** MDL050 flagged `formatDateTime($obj/Mod.Assoc/Date, …)` and `formatDecimal(…) + $obj/Mod.Assoc/Attr` as CE0117. Re-verification against `mx check` on 11.12.1 (once the real #48 root cause — the dropped association target-entity step — was fixed) showed the premise was wrong on BOTH cases | The earlier "format-function + association" correlation was an artifact of TWO unrelated bugs, neither of which is about format functions: (1) **association navigation dropped its target-entity step** → CE0117 for ANY `$obj/Assoc/Attr` in an expression (see the "#48 root cause" row above), which happened to include the formatDateTime example; (2) **`formatDecimal($x, 2)` fails CE0117 on a PLAIN local decimal** with no association at all — a `formatDecimal`-signature bug, not an association issue. With (1) fixed, `formatDateTime($obj/Assoc/Date, …)` builds clean → MDL050 rejected valid code | `mdl/executor/validate_microflow.go` (removed `checkFormatWithAssociation`/`exprHasRenderFunc`/`exprHasAssociationNav`/`renderFuncsIncompatibleWithAssoc`) | Deleted the check, its call sites, its test (`TestValidateMicroflow_FormatWithAssociation`), and its bug-test. **Lesson (reinforced): even after "reproducing the failing neighbours", a rule can still be mis-premised if the neighbours share a DIFFERENT hidden bug — here the missing entity step. Verify a check is still valid after every related write-path fix; a correlation-based rule is fragile.** The remaining real issue — `formatDecimal($x, precision)` failing CE0117 regardless of associations — is a **separate open finding** (likely a wrong function signature/arity; investigate against `mx` and fix in the executor or flag via the function checker). Ledger finding #48 (root cause corrected; MDL050 retired) | -| **Attribute-over-association navigation in a microflow EXPRESSION** (`$T/Mod.Assoc/Attr` in a Change Variable / if-condition / return) passes `mxcli check` but `mx check` fails with **CE0117** "Error(s) in expression". Trips hardest when the module or entity name ends in a digit (`L48.`, `Account2.`) or when a decimal literal co-occurs (`$T/Mod.Assoc/Bal + 2.0`). Studio Pro supports this pattern | Mendix expression syntax needs the **target-entity step** in the path (`$T/Mod.Assoc/Mod.Entity/Attr`); the builder inserts it via `resolveAssociationPaths`→`resolvePathSegments`. Two bugs skipped that insertion: (1) `shouldPreserveExpressionSource` treated a `.` **preceded by a digit** as a decimal literal, so a name segment ending in a digit froze the whole expression's source and bypassed resolution (source is written verbatim); (2) a **legitimately** source-frozen expression (real decimal / whitespace) also bypassed resolution, so association nav + decimal in one expression broke even for non-digit modules | `mdl/visitor/visitor_microflow_statements.go` (`shouldPreserveExpressionSource` + `dotIsQualifiedNameSeparator`/`isIdentByte`); `mdl/executor/cmd_microflows_builder.go` (`resolveAssociationPaths` SourceExpr arm + `collectAttributePaths`) | (1) A `.` is a decimal point only when its adjacent digit run is a standalone number — NOT when the preceding token contains a letter/underscore (a qualified-name segment). `dotIsQualifiedNameSeparator` walks back over ident chars; a letter/`_` means name, not decimal. (2) For a source-preserved `SourceExpr`, still rewrite association paths **inside the raw source**: for each `AttributePathExpr`, `expressionToString(orig)` vs `expressionToString(resolve(orig))`; if they differ, `strings.ReplaceAll` in the source. Keeps decimal/whitespace fidelity AND inserts the entity step. Tests: `TestShouldPreserveExpressionSource_Decimals` (digit-module cases); example `mdl-examples/bug-tests/ledger-48-assoc-nav-digit-module.mdl`. **Verified: `mx check` → 0 errors on 11.12.1** for plain nav, if-then-else enum compare, and decimal-mixed. **Lesson: a heuristic that keys on a single character (`.`→decimal) collides with other grammar (qualified names); and "preserve source" and "resolve semantics" are in tension — a frozen source still needs its association paths fixed.** Ledger finding #48 (root cause) | -| An action property on a pluggable widget — e.g. `onClick: microflow …` on a DataGrid2 — passes `mxcli check --references` AND `mx check`, builds and runs, but is **silently discarded**: `DESCRIBE PAGE` omits it and the model round-trips minus the action, with no error or warning | The .def.json **generator** never emitted an `action` operation for `type="action"` properties (across 42 generated defs, no action operation existed at all). The writer reads only `propertyMappings` from the def, so with no action mapping it had nothing to write. The `.mpk` correctly declares the action slot; the gap is purely in generation | `mdl/executor/widget_defs.go` (`GenerateDefJSON` `case "action"` + `actionSourceForKey`); `mdl/executor/widget_engine.go` (`resolveMapping` `case "OnChange"`; `WidgetDefGeneratorVersion` bump 12→13) | Emit an `action` `PropertyMapping` for the action slots MDL can author: `onClick`→source `OnClick` (reads the widget's `Action` property, i.e. the `onClick:`/`Action:` alias) and `onChange`→source `OnChange`. The engine's **existing** `applyOperation "action"` → `builder.SetAction` path (nil-guarded) then serializes the `ClientAction` with its parameter mapping. Non-authorable action slots (DataGrid2 `onSelectionChange`/`onConfigurationChange`) return `""` from `actionSourceForKey` → no mapping (no MDL surface yet). Bump the generator version so existing projects regenerate. Tests `TestGenerateDefJSON_ActionMapping`; example `mdl-examples/bug-tests/ledger-67-pluggable-widget-action.mdl`. **Verified end-to-end: `mx check` → 0 errors on 11.12.1**, and the DataGrid2 persists the onClick microflow action with its `Thing: $currentObject` mapping (the raw page unit carries `L67.OnClick` + `L67.OnClick.Thing`). Ledger finding #67 (write path) | -| **General guard for the #67 class:** a real widget property that the generator doesn't map to a write path could be silently dropped (or, via an alias, slip through) with no diagnostic — the same failure mode as #67 for any unmapped property type (`expression`, `icon`, a future type, an action slot with no MDL surface) | The `WidgetDefinition.KnownProperties` field + the `MDL-WIDGET06` "recognized but not persisted" path already existed, but the **generator never populated `KnownProperties`**, so the warning never fired: an unmapped property either false-errored as MDL-WIDGET01 "no such property" or (via an alias like `Action`) passed silently | `mdl/executor/widget_defs.go` (`GenerateDefJSON` — populate `KnownProperties`); `WidgetDefGeneratorVersion` bump 13→14 | Compute `KnownProperties` purely from the two artifacts mxcli already has — **every `.mpk`-declared property key with no mapping in the generated def** (subtract PropertyMappings + aliases + ChildSlots + ObjectLists + mode mappings from the full key set). No per-widget knowledge. The existing `knownUnmappedProperties`/MDL-WIDGET06 check then WARNS "recognized but not persisted — the value will be dropped." **Three-way discrimination verified on DataGrid2 (11.12.1): a mapped property (`onClick`) is silent, a real-but-unmapped one (`rowClass`) warns MDL-WIDGET06, a truly-unknown one (`totallyBogusProp`) still errors MDL-WIDGET01.** Test `TestGenerateDefJSON_KnownPropertiesUnmapped`. **This is the tester's suggested general check — it catches the whole class without guessing which widgets support what; the `action` mapping remains the specific fix for onClick.** Ledger finding #67 (general guard) | -| **DESCRIBE read gap (follow-up to #67):** after the write fix, `DESCRIBE PAGE` still omitted a DataGrid2's `onClick` action — a describe round-trip silently lost it | The datagrid2 describe parse/output paths read datasource + columns + paging but never read the widget-level action; the param-aware `renderClientActionMDL` reader (used for button/onchange) was never called for a pluggable widget's action | `mdl/executor/cmd_pages_describe_pluggable.go` (`customWidgetPropertyActionMap`), `cmd_pages_describe.go` (`rawWidget.OnClick`), `cmd_pages_describe_parse.go` (datagrid2 branch), `cmd_pages_describe_output.go` (datagrid2 emit) | New `customWidgetPropertyActionMap` returns the raw `Forms$*ClientAction` map for a CustomWidget property (NoAction → nil); the read is wired in **two** describe paths — the `datagrid2` branch AND the **generic `pluggablewidget '…'` branch** (`!isKnownCustomWidgetType`, where **CustomChart — the finding's actual widget — is described**). Each renders it via `renderClientActionMDL` (param-aware) into `rawWidget.OnClick`; the output emits `onClick: ` (the generic branch's guard also fires on `w.OnClick != ""` so an action-only widget still gets its `pluggablewidget` header). **Verified end-to-end on BOTH DataGrid2 and CustomChart: describe → re-exec → `mx check` 0 errors, round-trip preserves the full `onClick: microflow …(param: $currentObject)` including the parameter mapping** (CustomChart quotes the param name, `"Data":`, which re-parses fine). Test `TestCustomWidgetPropertyActionMap`. Ledger finding #67 (DESCRIBE read gap closed for datagrid2 + generic pluggable/CustomChart) | -| Two loops in the same microflow reusing the same iterator name (`loop $R in … end loop; loop $R in … end loop`) pass `mxcli check` but `mx check` fails with **CE0111** "Duplicate variable name 'R'." at Loop | A Mendix loop iterator is scoped to the **whole microflow**, not to its loop — so the second loop re-creates an existing variable. No check tracked loop iterator names across a microflow | `mdl/executor/validate_microflow.go` (`checkDuplicateLoopVariables`, MDL052) | New **MDL052**: walk the microflow body (recursing through if/case/while/loop bodies) collecting `LoopStmt.LoopVariable` names; flag any name used by a second loop. Catches sequential AND nested reuse (a nested loop reusing an outer iterator is also CE0111). Distinct iterators, a single loop, and the same name across DIFFERENT microflows are fine. Fix for the user: give each loop a distinct iterator (`$R`, `$C`, `$M`). Test `TestValidateMicroflow_DuplicateLoopVariable`; repro `mdl-examples/bug-tests/ledger-64-duplicate-loop-variable.fail.mdl`. Ledger finding #64 | -| A `break` nested inside an `if`/`case` within a loop passes `mxcli check` but serializes a **dangling sequence-flow reference** — `mx check` then **CRASHES loading the project** with an unhandled `System.AggregateException` ("key … not present in the dictionary"), an unrecoverable failure. A break placed **directly** in the loop body serializes fine | The flow builder (`addBreakEvent`) creates the Break event but the sequence flow connecting it from inside a conditional dangles — a write-path (flow-graph serialization) bug, still open. `break` directly in the loop body wires correctly | `mdl/executor/validate_microflow.go` (`loopBodyHasConditionalBreak`/`stmtsContainBreak`, MDL051) — **interim check**, pending the serialization fix | New **MDL051**: on a `LoopStmt`, scan its body for a `break` inside an if/case/inheritance-split (not descending into nested loops — a nested loop traps its own break); a direct-child break is not flagged. Since the pattern currently produces a *crash*, a check-time rejection is a strict improvement. Fix for the user: a guard variable (`declare $Done Boolean = false; … if not($Done) then … set $Done = true`) — **verified clean on mx check**. Test `TestValidateMicroflow_ConditionalBreak`; repro `mdl-examples/bug-tests/ledger-52-break-in-conditional.fail.mdl`. Verified on 11.12.1: conditional break → KeyNotFound crash, direct break + guard-var workaround → 0 errors. **The real fix is the break-in-conditional flow serialization (write path) — MDL051 is the interim guard.** Ledger finding #52 | -| `set $Match = contains($Hay, $Needle)` over two **String** values was serialized as a **List operation** activity 'Contains', so string `contains(haystack, needle)` was unusable: a pre-declared Boolean `$Match` collided (**CE0111** "Duplicate variable name"), and a String input to a list op is otherwise rejected (CE0023/CE0097). `contains` in an IF condition worked (stayed an expression) | `contains` is overloaded — a LIST operation `contains(list, object)` **and** a STRING function `contains(haystack, needle)` — but the visitor unconditionally rewrote `set $x = contains(...)` into a `ListOperationStmt`, **dropping literal arguments** (`extractVariableName` returns "" for a literal). A list operation CREATES its output variable; the string function assigns to a pre-declared one | `mdl/visitor/visitor_microflow_statements.go` (`buildSetStatement` CONTAINS case + `isPlainVariableArg`) + `mdl/executor/cmd_microflows_builder_actions.go` (`addListOperationAction`) + `mdl/executor/cmd_microflows_builder_validate.go` (ListOperationStmt case) | **Write-path fix, two parts.** (1) Visitor: a literal/computed argument means the string function unambiguously → fall through to `MfSetStmt` (a Change Variable value expression); both-plain-variables stays a `ListOperationStmt` (ambiguous, kind unknown at parse). (2) Flow builder: when `declaredVars[InputVariable] == "String"`, emit a **Change Variable** action carrying `contains($In, $Second)` instead of a `ContainsOperation`; the validate pre-pass mirrors this (validate the target IS declared, don't flag CE0111 duplicate). Genuine list form (list var + object var) unchanged. Tests `TestContainsOverloadParsing` (visitor), `TestBuildContains_StringVsList` (builder); example `mdl-examples/bug-tests/ledger-53-string-contains.mdl`. **Verified: `mx check` → 0 errors on 11.12.1** for literal-arg, two-String-var, and genuine-list forms. Ledger finding #53 | -| `set $At = find($Raw, '"id":"')` — the STRING function `find(haystack, needle)` (substring index) — was serialized as a List operation activity 'Find by expression', so a pre-declared `$At` collided (**CE0111** "Duplicate variable name 'At'"). String `find()` was unusable. Same overload trap as #53 (contains) | `find` is overloaded — the LIST operation `find(list, condition)` (filter by a boolean condition) and the STRING function `find(haystack, needle)` → index — but the visitor unconditionally rewrote `set $x = find(...)` into a `ListOperationStmt` (list find), and a list op CREATES its output variable | `mdl/visitor/visitor_microflow_statements.go` (`buildSetStatement` FIND case + `isStringLiteralArg`) + `mdl/executor/cmd_microflows_builder_actions.go` (`addListOperationAction`) + `mdl/executor/cmd_microflows_builder_validate.go` (ListOperationStmt case) | **Write-path fix, mirrors #53.** (1) Visitor: a **string-literal** second argument (you never filter a list by a bare string literal) means the string function → fall through to `MfSetStmt`; a boolean condition or both-plain-variables stays a `ListOperationStmt`. (2) Flow builder + validate pre-pass: when `declaredVars[InputVariable] == "String"`, emit a **Change Variable** carrying `find($in, )` (arg1 is stored in the stmt's `Condition`) instead of a list FindOperation, and validate the target as a pre-declared SET (no CE0111). The contains and find String-input handling now share one branch. Tests `TestFindOverloadParsing` (visitor), `TestBuildContains_StringVsList` find cases (builder); example `mdl-examples/bug-tests/ledger-63-string-find.mdl`. **Verified: `mx check` → 0 errors on 11.12.1** for literal-arg, two-String-var, and genuine-list-condition forms. Ledger finding #63 | -| A datagrid **column** with an explicit empty caption (`Caption: ''`) passes `mxcli check` but `mx check` rejects the page with **CE0463** "The definition of this widget has changed" on the Data grid 2 (error points at the widget version, not the caption). Omitting the caption, or a non-empty string, both build clean | The pluggable widget engine's column-header fallback treated a **present-but-empty** header property as "has header" and skipped the attribute-name default — so `Caption: ''` emitted an empty header (which Studio Pro rejects) while an **absent** caption got the fallback. The keyword datagrid path already handled it (`if caption == "" { caption = col.Attribute }` in `datagrid_column.go`) | `mdl/executor/widget_engine.go` (`applyColumnHeaderFallback`) | **Write-path fix.** Detect an empty header (a `texttemplate` op with empty `TextTemplate` and no `Parameters`) and treat it like an absent one: fill it **in place** with the bound attribute's leaf name (not appended — that would duplicate the `header` prop). A header WITH params (`Caption: '{1}'`) is left untouched. Result: `Caption: ''` now behaves like omitting it. **Round 2 (custom-content columns):** the first fix left a column with **no bound attribute** (an action/custom-content column) untouched — it had nothing to derive a header from, so an empty OR absent caption still tripped CE0463 (a custom-content column requires a non-empty header). Fixed by falling back to the **column's own name** when there's no attribute, and **gating the whole fallback on the item template having a `header` slot** (`mapping.ItemProperties`) so header-less object-list items (chart series, accordion groups) are never given a spurious header. `applyColumnHeaderFallback(spec, columnName, hasHeaderSlot)`. Test `TestApplyColumnHeaderFallback` (cases 1–8); examples `ledger-54-empty-column-caption.mdl` + custom-content verified via exec. **Verified: `mx check` → 0 errors on 11.12.1 for attribute columns AND custom-content columns (empty + absent caption)** (previously CE0463). Ledger finding #54 (custom-content columns) | -| Follow-up to round 2 (#25 re-test): the subscriber is registered but `runtime.log` **still** holds only the 4 JVM-banner lines — a probe microflow's `log info/warning/error` and a forced runtime exception produce **zero** lines. Isolation test: boot+subscriber → 0 probe lines; then call `start_logging` → 6 lines | A standalone runtime boots with logging **not started**, so a registered subscriber sits **inert** — nothing is delivered until the logging subsystem is activated. `create_log_subscriber` alone is necessary but not sufficient | `cmd/mxcli/docker/runtime_controller.go` (`configureRuntimeLogging` — renamed from `attachFileLogSubscriber`; now also calls `start_logging`) | After `create_log_subscriber`, call `CallM2EE(c.opts, "start_logging", nil)` in the same step (order: create subscriber → start_logging). Treat an "already started" response as success (`start` re-runs on the DB-update retry / restart paths on a still-running JVM). Still best-effort (warn to Stdout, never fail an up runtime). Test `TestStart_StartLoggingAlreadyStartedIsSuccess`; `TestStart_AttachesLogSubscriber` now asserts the `[start, create_log_subscriber, start_logging]` sequence. **Diagnosis pattern**: when a registered sink receives nothing, check whether the subsystem that feeds it is even *running* — registration ≠ activation. Findings #25 (round 3) | -| `mxcli new --version X` prints "Resolving MxBuild X..." and then produces a project at a **different** Mendix version — silently. Every later step (init, mxbuild, runtime, `run --local`) follows the wrong version | `ResolveMxForNewProject` delegated to `ResolveMxForVersion`, whose last resort is `AnyCachedMxPath()` — *any* cached mx, of any version. That fallback is fine when the project already exists and its version is a preference; for `new` the requested version **is** the output, because `mx create-project` stamps the project with the version of the binary that ran it | `cmd/mxcli/docker/check.go` (`localMxForVersion`, `ResolveMxForNewProject`) + `cmd/mxcli/cmd_new.go` (postcondition) | Resolve **exactly** the requested version for `new` (exact Studio Pro install → exact versioned install path → exact download cache; **not** PATH, which carries no version guarantee), and download otherwise. Then check the postcondition: reopen the created `.mpr`, compare `ProductVersion` to `--version`, and fail loudly on a mismatch — resolution bugs are invisible without it. **Generalisable**: when a flag names the version/identity of the artifact being produced, a "close enough" local substitute is never valid, and the produced artifact should be verified against the request rather than the resolution trusted. Found while reproducing #812 in a browser — cost a full project rebuild before it was noticed | -| A **DataView** property parses, passes `mxcli check`, and has no effect — `FormOrientation: Vertical` (#762) or `showFooter: true` (#813). `FormOrientation` works under `--engine legacy` | Two different causes that look identical. (a) `FormOrientation` has no BSON field: Studio Pro's radio **is** `LabelWidth` (0=Vertical, 3=Horizontal default). Only the legacy writer translated it; the modelsdk writer emitted `LabelWidth` solely when set explicitly, so the orientation was read into the model and dropped — the #812 shape, a model field no active-engine writer reads. (b) `ShowFooter` was only ever set implicitly by a `footer { … }` block; the property sat in the validator allow-list, so it parsed and was discarded | `sdk/pages/pages_widgets_data.go` (`ResolvedLabelWidth`), `mdl/backend/modelsdk/widget_write.go`, `sdk/mpr/writer_widgets_display.go`, `mdl/executor/cmd_pages_builder_v3_widgets.go` | Put the derivation **on the model** (`ResolvedLabelWidth`) so both writers share one definition instead of one owning it, and emit `LabelWidth` unconditionally. For the property, read it explicitly and let it win over the implicit block in both directions. **Trap**: `WidgetV3.GetBoolProp` is case-SENSITIVE and accepts only a real `bool`, unlike `GetStringProp` — so `showFooter: true` read as `false` even after the key was found. Coerce from the looked-up value and refuse a nonsense one instead of defaulting to false. Repro `mdl-examples/bug-tests/762-813-dataview-properties.mdl`. Issues #762, #813 | -| Every mxcli-authored page carries a container nobody asked for — a `Forms$DivContainer` named `conditionalVisibilityWidget` wrapping the page's top-level widgets. Creating a single button yields a button **and** a container | The builder wrapped each non-empty layout placeholder, because `pages.LayoutCallArgument` declared a **single** `Widget` field while the BSON `Forms$FormCallArgument` carries a **`Widgets` array**. The wrapper existed only to squeeze N widgets through a 1-widget field — never a BSON requirement | `sdk/pages/pages_parameters.go` (`LayoutCallArgument.Widgets`), `mdl/executor/cmd_pages_builder_v3.go`, `sdk/mpr/writer_pages.go`, `mdl/backend/modelsdk/page_write.go`, `mdl/backend/mcp/page.go` | Make the field a list and place widgets directly. **Check the claim against Mendix's own output before believing a comment**: ours said the wrapper is what "mxcli (and Studio Pro) adds", but `Administration.Account_Overview` in a `mx create-project` app has *two* top-level widgets in one placeholder and zero wrappers — same reasoned-by-analogy error as #812/#295. Corroborating signal that a construct is wrong: DESCRIBE already unwrapped it as a "phantom CONTAINER" and the catalog skipped it as "transparent" — three places working around something that should not be created. **Keep those readers**: projects authored before the fix still contain wrappers. Repro `mdl-examples/bug-tests/760-no-placeholder-wrapper.mdl`. Issue #760 | -| `CREATE CONFIGURATION` (or any `ALTER SETTINGS`) reports success and `mx check` passes, but Studio Pro throws `System.InvalidOperationException: Sequence contains no matching element` at `MprProperty.cs:25` when the changed unit is opened (e.g. from the version-control status grid). Silently, the same write also resets **HttpPortNumber/ServerPortNumber to 0** on every *existing* configuration | Three storage-name/enum defects in the settings write, all invisible to mxbuild (its deserializer tolerates unknown properties; Studio Pro resolves each stored property against the type's property list and throws when there is no match). (1) `createConfiguration` hardcoded `DatabaseType: "HSQLDB"` — the enum member is `Hsqldb`. (2) The gen `Configuration` binds the ports as `RuntimePortNumber`/`AdminPortNumber` (SDK names) while Studio Pro stores `HttpPortNumber`/`ServerPortNumber`, so the read returned 0 and the overlay wrote that 0 back. (3) Mendix renamed the runtime Java version property between 11.6 (`JavaVersion` = `"Java21"`) and 11.12 (`JavaMajorVersion` = `"21"`); mxcli wrote the 11.6 name unconditionally, leaving `JavaMajorVersion` stale and adding a property 11.12 does not define | `mdl/executor/cmd_settings.go` (`settingsDatabaseType`, `createConfiguration` defaults), `mdl/backend/modelsdk/settings_read.go` (`rawInt`, `javaVersionOf`), `mdl/settingsoverlay/settingsoverlay.go` (`JavaVersionKey`/`SetJavaVersion`, `newServerConfiguration`), `sdk/mpr/parser_settings.go` + `writer_settings.go` | Canonicalise enum-valued settings against `generated/metamodel` and reject the rest (executor **and** `mxcli check`, via a `settingsKind*` entry so the drift guard covers it). Read version-renamed properties off the stored document and write them back to the key they came from — **never invent a key the document does not already have** (the same reasoning removed the hardcoded `Tracing: nil` from the no-sibling fallback: 11.12 spells it `OpenTelemetry`). **Diagnose without Studio Pro**: dump the `Settings$ProjectSettings` unit before and after the command and diff key-by-key against the project `mx create-project` produced — the write must be purely additive. A "no matching element" *property* lookup means a key Mendix does not know; an enum member mismatch is a different exception. Repro: `create configuration 'X'` on an 11.12 project. Issue #759 | -| Any `ALTER SETTINGS` / `CREATE CONFIGURATION` corrupts a **private** constant override: the stored `Settings$PrivateValue` comes back carrying `"Value": ""`. Studio Pro then throws `System.InvalidOperationException: Sequence contains no matching element` at `MprProperty.cs:25` on open. `describe settings` separately renders the override as `value ''`, so replaying describe's own output converts it to a *shared* empty override | A constant override's value is either a `Settings$SharedValue` (carries `Value`, lives in the shared model) or a `Settings$PrivateValue` — a **marker type with no properties at all**, meaning the value is on the developer's workstation and deliberately out of version control. The overlay assumed SharedValue and wrote `cv.Value` (always `""` for a private override) into whichever node it found; the read type-asserted to `*SharedValue`, failed, and returned `""` with no way to distinguish private from empty | `mdl/settingsoverlay/settingsoverlay.go` (`constantValue`, `PrivateValueType`), `mdl/backend/modelsdk/settings_read.go` (`isPrivateConstantValue`), `sdk/mpr/parser_settings.go` (`parseConstantValue`), `mdl/executor/cmd_settings.go` (`describeSettings`, `alterSettingsConstant`) | Carry the distinction in the model (`model.ConstantValue.IsPrivate`) and **preserve, never author**: leave a PrivateValue node byte-identical, have `describe` emit a comment instead of a re-executable statement, and refuse an `alter settings constant` that would flip private→shared (drop is still allowed — it discards the whole override, which is what was asked). **Generalisable**: a polymorphic child whose variants differ in *arity* (one carries a value, one is a bare marker) cannot be overlaid by field assignment — branch on `$Type` first. Blast radius is wider than it looks: configurations are shared in version control, so one developer's unrelated edit corrupts every developer's private overrides and pushes the result. Found from a user describing their workflow, not from a filed issue | +## Finding a prior fix + +The findings live in `findings/*.jsonl`, one JSON object per line, sharded by +area. They are **data, not reading material**: 630 findings at ~1.7 KB each do +not fit in a context window, and the table they used to live in had grown past +the point where GitHub's web editor would open it. + +Grep is the fast path — the shard name narrows it, and every line is +self-contained: + +```bash +grep -il 'CE0463' .claude/skills/fix-issue/findings/*.jsonl # which areas +grep -h 'CE0463' .claude/skills/fix-issue/findings/mdl-executor.jsonl | jq . +``` + +For anything with a shape to it, the records have fields — `area`, `symptom`, +`cause`, `file`, `insight`, plus `refs` / `ce` / `rules` where the text carried +them — and DuckDB reads the files in place, no import step: + +```bash +duckdb -c "select symptom, file from '.claude/skills/fix-issue/findings/*.jsonl' + where list_contains(ce, 'CE0463')" +``` + +A finding whose original row could not be split into four columns keeps its +`raw` line instead; `symptom` and friends are then absent, so filter on `raw` as +well when a query must cover everything. + +`docs-wiki/bug-patterns/` is the layer above this: the *classes* of failure, +small enough to read. Start there, come here for the instance. + +## Recording a fix + +Append one line to the shard for the area you touched — a new shard is fine if +none fits. Keep it one line: `merge=union` in `.gitattributes` resolves two +concurrent appends by keeping both, which is correct for a file of independent +records and is not correct for prose. + +```bash +cat >> .claude/skills/fix-issue/findings/mdl-executor.jsonl <<'JSON' +{"area":"mdl/executor","symptom":"...","cause":"...","file":"...","insight":"...","refs":["#123"]} +JSON +make check-findings +``` + +Write the **insight**, not the changelog: what would have made this cheaper to +find, what measurement settled it, and which plausible-sounding wrong turn to +skip. Position carries no meaning — these are looked up by matching a symptom, +never read in order. + --- @@ -343,57 +151,6 @@ cases for these three BSON types — they fell to `default: return nil`. | A `create json structure … snippet` produces a structure whose **import mappings silently import zero objects** — the REST call succeeds, the mapping looks right, and no data arrives. No validation error, `mx check` passes. Dumping the stored BSON shows every element at `MinOccurs=0, MaxOccurs=0` | Mendix reads `MaxOccurs=0` **literally as "never occurs"**, not as "unspecified". The snippet→element builder hardcoded `0/0` at all nine construction sites; `cmd_import_mappings.go` then copies MinOccurs/MaxOccurs straight onto the mapping elements, so the dead bound propagates from the structure into every mapping bound to it | `mdl/types/json_utils.go` (`BuildJsonElementsFromSnippet` + `buildElementFromRawObject` / `buildElementFromRawRootArray` / `buildElementFromRawArray` / `buildValueElement`) | Derive occurrences from the JSON shape: root `1..1`, Object/Value `0..1`, Array `0..1` with its **item** child `0..*` (`MaxOccurs = -1`), primitive-array Wrapper `0..*`. Added the named constant `occursUnbounded` so `-1` is not a bare literal. **Generalisable — the shape to look for**: when a tree comes out uniformly wrong except for *one* node, that node proves the writer is capable of the correct value and localises the bug to the construction sites that hardcode it — here the nested object-array item was already `0..-1` while the root-array item beside it was `0..0`, i.e. the same construct written two ways in two builders. **Trap**: `0` looks like a harmless default for a numeric field, so this reads as correct in review and survives every checker; only a BSON dump or a runtime import reveals it. **Follow-up that the first cut missed**: Mendix cross-validates every *mapping* element's occurrence against its bound *schema* element and reports **CE5015** ("Attribute 'MaxOccurs' does not match schema element") on a mismatch. Import writers already propagated occurrences; all **three** export writers (`modelsdk/mpr/serialize_mappings.go`, `sdk/mpr/writer_export_mapping.go`, `mdl/backend/modelsdk/mapping_write.go` — the last is the one the default engine actually uses) hardcoded `MaxOccurs: 0` on value elements, so raising the schema broke every export mapping. **Generalisable — the shape to look for**: changing a value that another document is validated *against* is never a one-sided edit; grep every writer that emits the same key, and expect more than one engine to have its own copy. **Verification trap that caused the miss**: the repro created JSON structures but no mapping bound to one, so it passed `mx check` while the integration suite went red — when a fix changes a field that other documents reference, the repro must instantiate a *referencing* document, not just the changed one. Repro `mdl-examples/bug-tests/841-json-structure-occurrences.mdl` (now includes an import+export mapping); verified end-to-end (`mx check` 11.13.0 0 errors on the repro and on both doctype scripts that failed CI). Issue #841 | | A workflow **`DECISION`** whose expression uses the documented lowercase `$workflowContext` fails `mx check` with **`[error] [CE0117] "Error(s) in expression." at Decision 'Decision'`**, while the *same spelling* in a `CALL MICROFLOW … WITH` clause works. `mxcli check` and `mxcli exec` both report success | The context parameter is named `WorkflowContext` and Mendix expressions are case-sensitive on 11.9+, so `$workflowContext` is an undefined variable. `normalizeWorkflowContextExpr` existed and was well-tested, but was only *applied* in `autoBindCallMicroflow` (the FINDINGS #39 fix) — `buildExclusiveSplit` stored `n.Expression` verbatim. The working WITH clause is what disguised it: the user reasonably concludes the spelling is fine | `mdl/executor/cmd_workflows_write.go` (`buildExclusiveSplit`, and the sibling `buildWaitForTimer` whose delay may reference a context date attribute) | Run the authored expression through the existing `normalizeWorkflowContextExpr` at every site that accepts a user expression — there are three in the workflow writer, and only the parameter-mapping one was covered. **Generalisable — the shape to look for**: a *normalizer that exists and is unit-tested* is not evidence it is *called*; grep the call sites, not the helper. When one input spelling works and an identical one fails, compare the two code paths before questioning the data. **Also fix the docs that teach the broken form** — `.claude/skills/mendix/write-workflows.md` showed lowercase in its DECISION example and is synced into user projects by `mxcli init` via `cmd/mxcli/skills/`, so the bug propagated to every generated project. Repro `mdl-examples/bug-tests/845-workflow-decision-context-casing.mdl`; verified end-to-end (`mx check` 11.13.0: 1 error → 0). Issue #845 | -| `mxcli check … --references` reports **"All references valid" / "Check passed!"** for a script whose GRANT names a module role from a different module than the document; `exec` then fails with **CE0148** — after the preceding statements have already been applied, leaving the project half-modified | The guard (`checkDocumentAccessRolesSameModule`) existed and was wired into all five exec paths, but **no validate path ever called it**. mxcli does not run a script in a single transaction, so a failure that only surfaces at exec time is exactly what a pre-flight check exists to prevent | `mdl/executor/validate_grant_roles.go` (`ValidateGrantRoles`, MDL-GRANT01), `mdl/executor/cmd_security_defaults.go` (`validateCrossModuleGrant`), wired in `cmd/mxcli/cmd_check.go` | Reuse the existing exec-time guard from the **no-project** violations pass, covering all five document-access grants (microflow, nanoflow, page, OData service, published REST service). Take the document's module from the **statement's own qualified name**, not the resolved document — same comparison, and it works before the document exists (it is often created earlier in the same script). **Put it in the no-project pass, not under `--references`**: the check compares two names already in the script, so requiring `-p` withholds an answer mxcli can always give, and a plain `mxcli check` now catches it. **Generalisable — the shape to look for**: when exec rejects something a checker accepts, the bug is usually not a missing rule but a rule wired into only one of the two paths — grep the guard's callers before writing a new one. Repro `mdl-examples/bug-tests/836-check-cross-module-grant.fail.mdl`; verified end-to-end (check exits 1 naming the statement and CE0148, project left untouched; same-module variant still passes and gives 0 errors under mx check). Issue #836 | - -| `DESCRIBE MICROFLOW` emits **`on error rollback`** on activities authored with no error-handling clause at all, growing the diff on every round-trip. No checker flags it — `"Rollback"` is structurally valid, so `mx check` and every mxcli validator pass | `Rollback` is what `convertErrorHandlingType(nil)` stores for an activity with no clause **and** what the parser falls back to when `ErrorHandlingType` is absent from the BSON. The stored value therefore cannot distinguish an authored clause from the default, and read-back guessed "authored" | `mdl/executor/cmd_microflows_show_helpers.go` (`formatErrorHandlingSuffix`) | Drop the `Rollback` case so it falls through to no suffix. **The asymmetry is the whole argument**: omitting it is lossless (re-executing stores `Rollback` again, so the model is unchanged), while emitting it is lossy in the direction that matters — it puts a clause in the user's script that they never wrote. `Continue` / `Custom` / `CustomWithoutRollback` are never defaults, so they still round-trip. **Generalisable — the shape to look for**: when a formatter renders an enum whose zero/fallback value is also a legal authored value, read-back cannot invert the write; render only the values that are *never* defaults. Ask "what does the parser fall back to?" before trusting a stored enum to mean the author chose it. Repro `mdl-examples/bug-tests/840-describe-invents-on-error-rollback.mdl`; verified end-to-end (describe → exec → describe byte-identical, `mx check` 11.13.0 0 errors). Issue #840 | - - -| `mxcli check` **segfaults** (`SIGSEGV` in `visitor.(*Builder).ExitSqlDisconnect`) on the one-line script `SQL DISCONNECT source;` — which is the exact line in mxcli's own `mxcli syntax sql` example. Other aliases (`mydb`, `src`) are fine | Two defects. **Grammar**: every `sqlStatement` alternative took a bare `IDENTIFIER` for the alias/driver/table, but `source` lexes as `SOURCE_KW` (`MDLLexer.g4:317`), so no alternative matched. `IMPORT FROM identifierOrKeyword` in the same file already accepted a keyword alias, so `import from source …` worked while `sql source …` did not. **Robustness**: ANTLR error-recovers and still walks the tree, so the listener received a context whose `IDENTIFIER()` was nil and `.GetText()` crashed the process — sibling handlers guarded their children (`if len(ids) < 2 { return }`), this one did not | `mdl/grammar/domains/MDLSettings.g4` (`sqlStatement` → `identifierOrKeyword`), `mdl/visitor/visitor_sql.go` (`sqlWords` helper + guards at all five handlers) | Use `identifierOrKeyword` for every user-chosen word, and read children through a helper that tolerates a missing node. **Generalisable — the shape to look for**: a listener that dereferences `ctx.X()` without a nil check is a crash waiting for the first input that fails to parse *that rule* — ANTLR does not stop the walk on a syntax error. Grep for `ctx\.[A-Z][A-Za-z]*()\.GetText()` and treat each as unguarded. **Second shape**: when a bare `IDENTIFIER` names something a user picks (alias, table, column, prefix), it silently forbids every keyword; `identifierOrKeyword` is nearly always what was meant, and the inconsistency shows up as "works in one statement, not the neighbouring one". **Watch the index shift** when converting: the alias joins the `AllIdentifierOrKeyword()` list, so downstream `[0]`/`[1:]` offsets move (this bit `ExitSqlGenerateConnector`, where getting it wrong would generate a connector into a module named after the connection). **How it was found — and the trap it hid**: `TestExamplesParse` (new) feeds every `mxcli syntax` example through the parser; the panic **aborted the test binary**, masking ten further failures in entries sorted alphabetically after `sql`. A crash in a table-driven test is not one failure, it is an unknown number. Repro `mdl-examples/bug-tests/sql-keyword-alias-crash.mdl` | - -| `mxcli syntax ` teaches MDL that does not parse — `Binds:` (removed in favour of `Attribute:`, and hard-rejected by the parser), workflow decision outcomes without the `->` arrow, `ALTER WORKFLOW … SET DUE DATE = '…'` (no `=`), `INSERT AFTER ` (operands reversed), `IMAGE 'name'` (identifier, not string), a `BEFORE` that does not exist. Nothing failed, because nothing checked | The registry (`cmd/mxcli/syntax/features_*.go`) is hand-maintained while the grammar moves underneath it, and every existing test checked *structure* — fields populated, aliases resolve, see-also targets exist — never whether the documented MDL is real. 26 of 120 entries had a non-parsing example | `cmd/mxcli/syntax/example_parses_test.go` (new guard), plus corrections across `features_page.go`, `features_workflow.go`, `features_integration.go`, `features_microflow.go`, `features_misc.go` | Parse every `Example` and fail the build if it does not. Examples come in several legitimate shapes, so each blank-line-separated **block** is tried as a statement, a microflow activity, a page widget, a workflow activity, and a retrieve clause — a failure means it parses as none of them. Only `Example` is checked; `Syntax` carries metasyntax (`[OR MODIFY]`, ``) by design. **Generalisable — the shape to look for**: documentation that is *data in the binary* can be executed against the real parser, which turns "the docs drifted" from a review problem into a test failure. The same trick applies to any embedded example corpus (skills, `--help` text, README snippets extracted at build time). **Why it matters more than it looks**: the registry is the first surface an agent consults, so a gap there does not read as "undocumented", it reads as "unsupported" — a contact-management app built with mxcli worked around three features that already existed, including replacing a `SAVE_CHANGES CLOSE_PAGE` button with a bespoke microflow because only the two halves were listed separately. **Close the opt-out** — a guard that skips empty input is a guard you can silence by emptying the field; `TestFeatureFieldsPopulated` already rejects a blank `Example`, and the new guard additionally fails an example that is all comments | -| SCSS written to **`themesource//web/main.scss` never reaches the app** — no error, no warning, the build succeeds and the rules are simply absent from `theme-cache/web/theme.compiled.css`. Looks exactly like an SCSS cache problem, so the usual reflex (`rm -rf theme-cache/`) wastes the session | A theme source folder is only compiled when `` matches a **real module in the model**. mxbuild walks the model's modules and pulls each one's `themesource//web/main.scss`; it never globs the `themesource/` directory, so an invented folder (`themesource/my_theme/`) is silently skipped. Verified on 11.13: a probe rule in `themesource/myfirstmodule/` compiled, the identical rule in `themesource/mxcli_theme/` did not | `cmd/mxcli/theme/theme.go` (package doc records the compile order); the target paths live in `cmd/mxcli/theme/assets//files/` | Put app-level styling in **`theme/web/`**, not in an invented theme source folder: `theme/web/main.scss` is compiled **last** — after Atlas Core *and* after every module theme source — so a partial imported from it overrides any Atlas rule without `!important`. Use a module's theme source only when the styling genuinely belongs to that module (it exports with the `.mpk`). **Generalisable — the shape to look for**: when CSS "doesn't apply", first prove the file is *compiled at all* (grep a unique probe selector in `theme-cache/web/theme.compiled.css`) before debugging specificity or caches — absent and overridden look identical in the browser. Note also that `theme/web/custom-variables.scss` is imported once **per module**, so it must hold declarations only; a rule there is emitted N times | - -| A **`CREATE JAVASCRIPT ACTION`** succeeds, `mxcli check` passes and the build is clean, but calling the action in the running app throws **`JavaScript action was not implemented`** and the nanoflow aborts | mxcli wrote the source to `javascriptsource//actions/` using the module's own casing. Mendix reads a **lowercased** directory — a blank Mendix 11 app ships `javascriptsource/nanoflowcommons/`, `/datawidgets/`, `/webactions/` for modules named `NanoflowCommons`, `DataWidgets`, `WebActions`. Finding no source at the path it reads, mxbuild generates a stub whose body is `throw new Error("JavaScript action was not implemented")` and bundles that. Only reproduces on a **case-sensitive filesystem**, which is why it survived: on macOS and Windows the two spellings are the same directory | `mdl/backend/modelsdk/javascript_write.go` and `sdk/mpr/writer_javascriptactions.go` (`jsActionSourceDir`) | `strings.ToLower(moduleName)` in both writers — the comment in each previously asserted the opposite ("unlike javasource, which is lowercased"), so the belief was documented, not tested. **Generalisable — the shape to look for**: when generated *source files* pair with model units, the model unit is not evidence the file is found; the filesystem path is a separate contract, and a case-only mismatch is invisible on the developer's own machine. Check against the directories a blank project already ships rather than against what the code says. Nothing short of running the app catches it: parse, check and build all pass. Test `mdl/backend/modelsdk/javascript_write_dir_test.go`; verified end-to-end (button click flips the theme instead of throwing) | -| A `create rest client` operation reports success, but `describe rest client` omits `Query:`, `Parameters:` and `Headers:` and always prints `Response: none`. BSON shows the query parameters/headers stored **correctly** while `ResponseHandling` is `Rest$NoResponseHandling` — the response mapping is gone. `mx check` passes (0 errors), so nothing anywhere complains | **Two unrelated defects with one symptom.** *Write*: `model.RestClientOperation` documents `BodyType`/`ResponseType` as UPPER-case tokens and every consumer compares against that spelling, but the MDL executor stored the visitor's lower-case source text — so `op.ResponseType == "MAPPING"` never matched and the mapping fell through to the else-branch, which legitimately writes `NoResponseHandling`. *Read*: `restOperationFromGen` populated only Name/HttpMethod/Path/Timeout and type-asserted `*genRest.RestParameter` for **both** parameter lists, while the writer emits `Rest$OperationParameter` and `Rest$QueryParameter` — two different gen types, so both assertions failed silently | `mdl/executor/cmd_rest_clients.go` (`buildRestClientOperation` normalization + `checkInlineMappingBody`), `mdl/backend/modelsdk/integration_read.go` (`restOperationFromGen` + response/body/mapping-tree readers), `mdl/backend/modelsdk/consumed_rest_write.go` and `modelsdk/mpr/serialize_web_services.go` (`EqualFold`), `mdl/executor/validate_rest_mapping.go` (MDL-REST01) | Normalize with `strings.ToUpper` at the one place the AST becomes the semantic model, and make the two serializer comparisons `EqualFold` so the landmine is not left armed for the next producer. **Generalisable — the shape to look for**: a case-sensitive comparison against a *documented-but-unenforced* string constant, where the non-matching branch is a **legitimate** outcome. Nothing errors, because "no response handling" is a real thing an operation can have — the else-branch launders a producer/consumer mismatch into a plausible-looking model. Grep every comparison against the constant, expect one per engine, and check whether the false branch is silent. **Second shape**: a reader that type-asserts one concrete type for two lists the writer builds from two *different* types; the assertion fails to `ok=false` and `continue`s, so a stub reader is indistinguishable from an empty document. The pre-existing round-trip test even created a query parameter — but only asserted the operation *count*, never that the parameter survived. **Third, separate half (#843's headline)**: `Response: mapping Mod.IMM_X` names an import mapping **document**, which Mendix cannot reference — `Rest$RestOperationResponseHandling` has exactly two implementations, inline and none. The clause parsed, contributed no entries, and was written as "none". Now refused at exec *and* `mxcli check` (MDL-REST01, no project needed). **Note** `Rest$QueryParameter` stores no DataType at all, so the MDL type is decorative and `describe` re-emits every query parameter as `String` — do not "fix" that by inventing the authored type back (see the #840 row). Repros `mdl-examples/bug-tests/843-rest-response-mapping.mdl` + `843-rest-response-mapping-no-body.fail.mdl`; verified end-to-end (`mx check` 11.13.0 0 errors, BSON now `Rest$ImplicitMappingResponseHandling` with a full `ImportMappings$ObjectMappingElement` tree, describe → exec → describe byte-identical). Issue #843 | -| A widget datasource bound to a **parameterized** microflow/nanoflow (`datasource: microflow Mod.MF(Name: $x)`) fails with **CE1571** "No argument has been selected for parameter 'X'". `mxcli check` and `exec` both report success; `describe page` shows the microflow but no arguments | The grammar parsed the arguments into `DataSourceV3.Args`, but the builder never read them and `pages.MicroflowSource` had **no field to hold them**, so they were dropped between AST and model. The writer's `microflowSettingsToGen(d.Microflow, nil)` passed a literal `nil` at all three datasource sites, with a comment asserting datasources never carry mappings — true when only actions could take arguments, stale once the grammar accepted them on a datasource | `sdk/pages/pages_datasources.go` (`MicroflowSource`/`NanoflowSource` gain `ParameterMappings`), `mdl/executor/cmd_pages_builder_v3.go` (`flowArgsToParameterMappings`, shared with the action path), `mdl/backend/modelsdk/widget_write.go` (three sites pass `d.ParameterMappings`) | Reuse the action path's `$`-variable-vs-expression rule rather than writing a second one — the two must agree or the same argument binds through different BSON fields depending on where it appears. **Generalisable — the shape to look for**: a hardcoded `nil` argument whose comment explains *why* the data can't exist is a dated assumption; when the grammar grows a construct, grep for the `nil`s that were correct before it. **Also check the model type can hold the value at all** — here the AST parsed it and the writer would have written it, but the struct in between had no field, so nothing was "dropped" by any single line. **Remaining gap**: `describe page` does not yet emit the mappings, so a describe → exec round-trip still loses them (read-back only; the write path is correct). Repro `mdl-examples/bug-tests/835-datagrid-microflow-datasource-params.mdl`; verified end-to-end (`mx check` 11.13.0: CE1571 → 0 errors). Issue #835 | -| Quoted identifiers inside a `create import mapping { }` / `create export mapping { }` body produce **CE1613** "The selected entity `'Mod."Entity"'` no longer exists" on every reference, while the same quoting works everywhere else. `mxcli exec` reports success with no warning | The mapping-body builders read entity/association names with `ctx.QualifiedName().GetText()`, which returns **raw parse text including the quotes**, instead of `buildQualifiedName` (which strips them via `identifierOrKeywordText`, as the rest of the visitor does) | `mdl/visitor/visitor_import_export_mapping.go` (`buildImportRootElement`, `buildImportChild`, `buildExportRootElement`, `buildExportChild` — five sites: root entity, nested association + entity, value-transform converter) | Route every qualified name through `buildQualifiedName(...).String()`. **The error message names the bug**: `'Mod."Entity".RouteId'` mixes a *quoted* entity with an *unquoted* attribute, because the attribute half already went through `identifierOrKeywordText` — when a stored name is half-stripped, one of the two readers is raw. **Generalisable — the shape to look for**: `GetText()` on a parser context is almost always wrong for a name; it is the raw source slice, so any lexical decoration (quotes, whitespace) survives into the model. Grep for `.GetText()` next to `QualifiedName` whenever a reference looks right in the script but not in the .mpr. Repro `mdl-examples/bug-tests/842-mapping-quoted-identifiers.mdl`; verified end-to-end (`mx check` 11.13.0: 3 errors → 0). Issue #842 | -| `ALTER PAGE … ON ` targeting a DataGrid2 column: (a) the authored MDL column name (`column colFoo`) fails with a bare `widget "colFoo" not found`, and (b) a name shared by two columns (duplicate captions) **silently mutates the first** and reports `Altered page`, leaving the second unreachable | DataGrid2 columns carry **no stored name** in the Mendix model (their WidgetObject has no `Name` property), so the authored name is dropped on write and mxcli addresses a column by a *derived* name (attribute leaf, else caption, else `col{N}`). The bare-name resolver `findInWidgetArray` returned on the **first** derived-name match, so duplicates collided silently; the miss path emitted a generic "widget not found" with no hint that columns use a derived name | `mdl/backend/pagemutator/mutator.go` — the column loop in `findInWidgetArray` (now counts matches → `bsonWidgetResult.matchCount`), `findBsonColumn` (returns `(result, error)`), and the mutation entry points `SetWidgetProperty`/`DropWidget`/`InsertWidget`/`ReplaceWidget` (reject `matchCount > 1`, use `widgetNotFoundError` which lists derived names) | Persisting the authored name is **not possible** — columns have no name slot, and inventing one is the Studio-Pro-won't-open hazard (ADR-0005 guard-don't-drop). So implement the finding's two fallbacks: reject an ambiguous `ON ` with an actionable error instead of silently taking the first (a *data hazard*, not just a wart), and on a miss list the addressable derived names + explain the derived-name model (run DESCRIBE PAGE). **Generalisable**: a resolver that returns the first of N matches hides ambiguity — count matches and reject >1 at every *mutating* entry point, not just the read path. Repro `mdl-examples/bug-tests/ledger-78-datagrid-column-addressing.mdl`; A/B: pre-fix binary reports `Altered page` for the duplicate-caption `ON "Amount"`, fixed errors with "ambiguous". Ledger #78 | -| `loop { if then break; }` where the `if` is the **last** statement builds a Decision with only its `true` outgoing flow (→ break). `mxcli check` passes but `mx check` reports **CE0079** "the 'false' condition value should be configured in properties for an outgoing sequence flow", and the microflow won't deploy. (Distinct from the earlier #791 crash — that was a dropped Break/Continue *event*; this is a missing *flow*.) `continue` and break-not-last behaved likewise | The loop-body flow builder (`addLoopStatement`) is a simplified copy of `buildFlowGraph` that connected body statements with a plain `newHorizontalFlow` and **never honoured the deferred `nextFlowCase`** a merge-less split leaves for its FALSE branch. So the split's false case was dropped: mid-body it wired the next statement with no case; as the last statement it wired nothing at all | `mdl/executor/cmd_microflows_builder_control.go` (`addLoopStatement` body loop) | Mirror `buildFlowGraph`: track `pendingCase` between body statements and apply it to the connecting flow; then, for a leftover `pendingCase` at the end of the loop body (a decision whose non-terminal branch falls off the end), synthesize a **ContinueEvent** and wire the split's false flow to it — the valid Mendix representation of "didn't break/return → next iteration". **Trap**: the check-time acceptance test (`TestValidateMicroflow_ConditionalBreakAccepted`) only asserted MDL051 doesn't fire — it never ran `mx check` on the *output*, so the CE0079 microflow shipped green. Assert the produced BSON, not just that check accepts the source. Repro `mdl-examples/bug-tests/ledger-52-break-in-conditional.mdl`; verified raw `mx check` 0 errors (was 1× CE0079) and the split now carries both a true→Break and a false→Continue flow. Ledger #52 | - -| A workflow containing a standalone `annotation '...'` writes a project Mendix **cannot load**: `System.InvalidOperationException: Type ...Workflows.Model.Annotation does not contain a constructor with a parameter of type ...Workflows.Model.Flow`. Not a build error — Studio Pro will not open the project and `mx check` dies before validating anything. `mxcli check` passed and `exec` succeeded | mxcli writes the annotation into the workflow's **activity flow**. Mendix loads that list by constructing every child with a `Flow` parent, and no annotation type takes one: `Workflows$Annotation` carries only `Description` (it attaches to a Flow) and `Workflows$FloatingAnnotation` (the canvas sticky note, which has exactly the `RelativeMiddlePoint`/`Size` fields mxcli was already writing) is not a flow element either. **Placement is the defect, not the storage name** — swapping the `$Type` to FloatingAnnotation reproduces the identical error with the new type name | `mdl/executor/validate_workflow.go` (new `MDL-WF04`), `mdl/executor/cmd_workflows_write.go` (`execCreateWorkflow` guard + `hasStandaloneWorkflowAnnotation`), skill `.claude/skills/mendix/write-workflows.md` (which had documented the construct) | **Refuse rather than emit an unopenable unit** — at check time *and* at exec time, because a user who skips `check` otherwise still loses the whole project. The correct container is not determinable from the gen model (no struct owns a `FloatingAnnotation` list) and CLAUDE.md's rule applies: when the BSON shape is unknown, get a Studio Pro reference rather than guess. **Generalisable**: verify a storage-name hypothesis by *swapping only the name* — if the error is byte-identical with the new type, the bug is where the element is attached, not what it is called. Repro `mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl`; A/B: pre-fix binary writes it and `mx check` cannot load the project, fixed binary refuses and the project checks 0 errors. issuetracker #15 **Follow-up (CI):** three `-tags integration` round-trip tests asserted this construct *works* and went red on the guard. They exercised mxcli's own write → read → describe → re-execute loop, which a structurally invalid document survives — the loop never loaded the project in Mendix, so it proved nothing about validity. Re-settled by stubbing the guard and running real `mx check`: the project fails at "Loading the mpr file". The tests were pinning the defect, and now assert the refusal (`TestCreateWorkflow_StandaloneAnnotationRefused`). **Second instance in one PR** of a green test codifying a bug (see the `jump to` row) — when a pre-existing test contradicts a new guard, re-derive the ground truth from the layer the symptom lives in before believing either. | - - -| A page datasource navigating an association writes `DestinationEntity: ""`, and the project becomes **unloadable**: `An error occurred when trying to set the 'DestinationEntity' property of a Entity ref step ... ---> System.ArgumentNullException` at `EntityRefStep.set_DestinationEntityId`. Studio Pro will not open it and `mx check` dies before validating anything. `mxcli check` and `exec` both succeed | `resolveAssociationDestination` resolves both ends via `entityQNByID`, which only sees the **project's own** domain models — an association ending in a **System** entity (`from W.Issue to System.Workflow`) yields `""` for that side. The context then matched neither end, and the fallback `return childEntity` returned the empty one. An empty by-name reference is not "absent", it is a reference Mendix resolves to null | `mdl/executor/cmd_pages_builder_v3.go` (`resolveAssociationDestination` fallbacks + a hard guard in the `association` case of `buildDataSourceV3`) | Prefer whichever end actually resolved and is not the context; then **refuse** an unresolved destination rather than write it, pointing at the explicit `Assoc/Module.Entity` form (verified to build 0 errors — it is the construct the reporter had abandoned). **Narrower than reported**: the finding blamed *nesting*, but a one-step probe with the same association reproduces it identically — nesting was incidental. Always re-derive the trigger with the smallest case before fixing the reported shape. **Generalisable**: a resolver that returns `""` on failure will silently produce a null by-name reference; make the write path refuse empty rather than trusting the resolver. Repro `mdl-examples/bug-tests/it-14-assoc-destination-entity.mdl`; A/B: pre-fix binary leaves the project unopenable, fixed binary refuses and the project checks 0 errors. issuetracker #14 | -| `describe workflow` renders a plain `jump to Review;` as `jump to Review comment 'Review';` — a comment clause the author never wrote, which then round-trips back into the model as a real caption | `buildJumpTo` defaults the activity's `Caption` to the **target name**, and the DESCRIBE emitter echoed `Caption` unconditionally (falling back to the activity `Name` when empty). Both are derived values carrying no authored information | `mdl/executor/cmd_workflows.go` (`JumpToActivity` case in `formatWorkflowActivities`) | Emit `comment '...'` only when the caption is genuinely authored — non-empty **and** different from both the target name and the activity name. **Watch for tests that codify the bug**: `TestFormatJumpTo_CaptionCommentFormat` had a "name fallback when caption empty" case asserting the phantom comment, and two issue-619 quoting tests were incidentally coupled to it; a green suite was pinning the defect in place. Tests `mdl/executor/cmd_workflows_describe_test.go`, `mdl/executor/issue619_emitter_quoting_test.go`. issuetracker #16 | -| A workflow `decision ''` referencing the context passes `mxcli check`, executes, then the build fails `[error] [CE0117] "Error(s) in expression." at Decision 'Decision'`. `$WorkflowContext/X` (exact casing) works; `$workflowContext/X` — the spelling this repo's own skill documented — and `$Ctx/X` from the author's `parameter $Ctx:` header both fail | mxcli always stores the context parameter as `WorkflowContext` and Mendix expressions are **case-sensitive**. `normalizeWorkflowContextExpr` already existed but was wired into `autoBindCallMicroflow` only, so `with (...)` mappings were normalized while a decision's condition was written through verbatim. Separately, the header's declared variable name was parsed into `ast.CreateWorkflowStmt.ParameterVar` and then **never consumed** — a field populated but read nowhere, so `$Ctx` resolved to nothing | `mdl/executor/cmd_workflows_write.go` (`contextExprNormalizer` + threading it through `autoBindActivitiesInFlow`), `mdl/executor/cmd_alter_workflow.go`, skill `.claude/skills/mendix/write-workflows.md` (whose examples were the failing spelling) | One normalizer applied to **every** expression an author can write in a workflow — decision conditions, user task due dates and XPath targeting, wait-for-timer delays, call-microflow mappings — rather than a second point fix. The declared name is aliased onto the stored one (whole-word, so `$CtxItem` is not mangled) instead of documenting it as meaningless. **Generalisable**: grep for an AST field that is written by the visitor and read nowhere — that is a silently-discarded user intent, not dead code. Repro `mdl-examples/bug-tests/it-17-workflow-context-expression.mdl`; A/B: pre-fix binary writes it and `mx check` reports CE0117, fixed binary checks 0 errors. issuetracker #17 | -| A page widget bound through an association — `Attribute: Issue_Assignee/Name` — passes `mxcli check`, executes, then fails `[error] [CE1613] "The selected attribute 'IT.Issue.Issue_Assignee/Name' no longer exists."`. The error text is the raw MDL path glued onto the context entity. Same-module paths (`Issue_Project/Code`) work | A domain model keeps associations in **two** lists. `Associations` holds intra-module ones (both ends BY_ID); an association targeting another module is a `DomainModels$CrossAssociation` in **`CrossAssociations`**, where only the local end is BY_ID and the remote end is the BY_NAME `ChildRef`. `associationEndpoints` searched only the first list, so every cross-module hop returned ok=false and the writer fell back to a flat attribute path instead of an `AttributeRef` with an `IndirectEntityRef` of steps | `mdl/executor/cmd_pages_builder_v3.go` (`associationEndpoints`, `resolveAssociationDestination`), `mdl/executor/widget_engine.go` + `cmd_pages_builder_input.go` (`resolveAssociationPathIn`, `storedSystemMemberName`) | **Scope correction — the reported trigger was wrong.** The finding blamed the *System module*; a plain second app module reproduces it identically, so the trigger is cross-module. Fixing "System" alone would have left the commoner case broken — always re-derive the trigger with a neutral variant before fixing the reported one. Two sibling defects in the same finding: (a) a ComboBox's `Association:` was qualified with the module of its **own option list**, because the `DataSource:` mapping runs first and moves `pageBuilder.entityContext` — an association belongs to the *containing* entity, so it now resolves against the context saved at `Build` entry (`outerEntityContext`); (b) `CreatedDate: AutoCreatedDate` is the spelling mxcli **requires** when declaring an audit member, but the member is stored as `createdDate`, so binding the name you just declared failed — `storedSystemMemberName` now maps declared→stored. **Generalisable**: when a resolver reads one collection off a model object, check whether the model splits that concept across two (intra- vs cross-module, own vs inherited). Repro `mdl-examples/bug-tests/it-19-cross-module-attribute-path.mdl`; A/B on Mendix 11.12.1: pre-fix binary → 4 × CE1613, fixed binary → 0 errors. issuetracker #19 | -| GRANT rejects members Mendix does recognise — `entity M.Label has no member(s) Issue_Label` for the non-owning end of an `OWNER Both` reference set, and `has no member(s) createdDate, changedDate` for audit members — and a `read * / write *` rule that looks complete still fails `[error] [CE0066] "Entity access is out of date."`, so partial coverage is worse than none | Two unrelated gaps in "what counts as a member". (a) `OWNER Both` makes an association a member of **both** ends, but the writer emitted the MemberAccess only for the FROM entity (`ParentID`) **and** `ReconcileMemberAccesses` independently applied the same FROM-only rule — so it stripped the entry back out on the next write even if the executor had added it. Two places had to agree. (b) Audit members are entity **flags** (`HasCreatedDate`/`HasChangedDate`), not entries in `entity.Attributes`, so the member walk never yielded them | `mdl/executor/cmd_security_write.go` (`execGrantEntityAccess`, `storedAuditMembers`, `otherModuleBothOwnerAssociations`), `mdl/backend/modelsdk/domainmodel_security_write.go` (`ReconcileMemberAccesses`) | **Ask mxbuild what it wants instead of inferring symmetry.** Emitting a MemberAccess for `createdDate` seemed like the obvious fix for (b) — mxbuild **rejects** it with CE0066, and an entity storing audit members checks clean with no entry. So audit members are accepted as names but per-member rights on them are **refused with the reason** rather than silently dropped; only the `OWNER Both` association actually needed a new entry. **When a symptom has two spellings (a rejection and a build error), check whether they are one bug or two** — here they were two, and fixing them the same way would have introduced a new CE0066. **Generalisable**: a writer and a reconciler that both compute "the expected member set" are one invariant in two places; changing one alone is silently undone. Repro `mdl-examples/bug-tests/it-20-grant-member-coverage.mdl` (+ `it-20-grant-audit-member-rights.fail.mdl`); A/B on Mendix 11.12.1, same module same project: pre-fix binary → CE0066 + the bogus rejection, fixed binary → 0 errors. Controlled: the identical model with `OWNER Default` checks clean pre-fix, so the owner mode is the trigger. issuetracker #20 | -| An unquoted negative number in an XPath constraint fails to parse: `where [Amount > -7]` → `Parse error: extraneous input '7' expecting {',', ')'}`. Reported as "negative numeric literals truncate (`-7` becomes `-`)" | `xpathWord` — the name-part rule inside XPath — is a **negated token set** that did not exclude `MINUS`, so the sign was consumed as a name word and the digits were left stranded (hence the truncation appearance). The lexer deliberately keeps `-` out of `NUMBER_LITERAL` (a leading sign there mis-tokenises `$x -2`), leaving negation to the parser; the general grammar has `unaryExpression` for this and the XPath grammar simply never got the equivalent | `mdl/grammar/domains/MDLPage.g4` (`xpathValueExpr` gains `MINUS xpathValueExpr`; `MINUS` added to the `xpathWord` exclusion set), `mdl/visitor/visitor_xpath.go` (`buildXPathValueExpr`), `mdl/visitor/visitor_page_v3.go` (`xpathExprToString` emits `-7`, not `- 7`) | **The grammar fix alone is worse than the bug.** With the parser accepting `-7` but the XPath AST builder having no case for the new alternative, the constraint parses and silently serializes to `[Amount > ]` — a dropped operand instead of a loud parse error. Caught only because the visitor has a round-trip helper; the microflow write path uses `GetText()` and looked fine. **When adding a grammar alternative, check every consumer of that rule, not just the one your repro exercises.** **Scope correction**: the finding's own example (`addDays([%CurrentDateTime%], -7)`) still fails — `addDays` is a *microflow expression* function, not an XPath one, and it fails `CE0161` with a POSITIVE argument too, so the sign was never its problem. Repro `mdl-examples/bug-tests/it-18-xpath-negative-literal.mdl`; A/B on Mendix 11.12.1: pre-fix the script does not parse, fixed binary writes it and `mx check` reports 0 errors. issuetracker #18 | - -| Text painted by an **Atlas topbar widget is invisible in a dark theme** — the language selector measures ~1.13:1 contrast, glyph pixels spanning 4 luminance values out of 255. A theme override exists and *names the right element*, so it looks handled | Two separate mistakes stacked. (1) **Specificity**: Atlas's own rule is `.navbar-brand .widget-language-selector .current-language-text` at (0,3,0); a bare `.current-language-text` at (0,1,0) never wins, and only appears to on layouts that do not nest the selector under `.navbar-brand`. (2) **Wrong value**: `color: inherit` inherits *body ink*, which is dark, while the rail is dark in both palettes — so even at the winning specificity it measures 1.00:1. Atlas paints from `--bg-color-secondary` with a `#fff` fallback because it assumes a dark rail | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-atlas-map.scss` (the "Atlas fixups" block) | Re-declare **Atlas's own selector shape** so the weights match and source order decides, and resolve the colour through the rail token (`var(--mxt-rail-ink-active, var(--mxt-rail-ink))`) rather than `inherit`. List the bare and the `.navbar-brand`-nested selectors together — each is matched at its own specificity, so one rule covers both layouts. **Generalisable — the shape to look for**: a guard that names the right element is not evidence it applies. Read the *winning* declaration (`CSS.getMatchedStylesForNode` in DevTools, or the computed value) instead of the one you wrote. **Measure contrast, not colour**: reading `getComputedStyle(el).color` once and seeing a plausible value proves nothing — compute the WCAG ratio against the first non-transparent ancestor background, which is what turns "looks fine" into 1.13 vs 19.47. Reported from the RssReader test build; tests in `cmd/mxcli/theme/theme_test.go`, verified in a browser at 17.79:1 light / 19.47:1 dark | - -| A themed app is on-palette everywhere except a few widget details — the **Data Grid 2 pager caption is invisible** (1.02:1 on a dark ground), row-select checkboxes stay stock Mendix blue, popovers cast light-mode shadows. Re-pointing tokens changes nothing, and the same widget's other parts (the pager *buttons*) are fine | The theme source shipped by the **widget modules** (`themesource/datawidgets`, `atlas_web_content`) styles some things with Sass variables and literals — `datawidgets/web/variables.scss:18` is `$pagination-caption-color: #0a1325`. Sass resolves those at compile time, before any custom property exists, so the value is baked into `theme.compiled.css` and no `--mxt-*` can reach it. The parts that *do* work resolve `var(--gray-darker, …)` through Atlas: same bar, two mechanisms | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-widgets.scss` (the shared widget layer, imported after the theme partial) | Add a CSS rule per baked declaration, resolving through a token so both palettes follow. **The obvious fix does not work**: each module's `main.scss` imports `theme/web/custom-variables` before its own `!default` vars, so `$pagination-caption-color: var(--mxt-ink-muted)` there *would* win — but (1) the names collide with Atlas Core's, which feeds them to Sass colour functions (`atlas_core/web/_variables.scss:20` computes `mix($brand-primary, #e7e7e9, 10%)`; handing `mix()` a `var()` is a compile error) and (2) the worst offenders are not behind a variable at all — `_three-state-checkbox.scss` writes `#264ae5` and `rgba(#264ae5, 0.4)` directly. **Generalisable — the shape to look for**: read the **compiled CSS, not the SCSS**, when deciding what to override. The sources are full of `var(--token, #fallback)` declarations that already resolve correctly; in one measured app `#264ae5` appeared in 46 declarations and **24 were harmless fallbacks**, so grepping the source would have produced twice the rules for no benefit. Reported from the Formula1 test build (§33); verified in a browser: pager caption 1.02:1 → 6.99:1 console dark, 6.39:1 light, 6.78/5.93 on signal | -| A loop's variable used after `end loop;` passes `mxcli check`, then `mx check` fails `[error] [CE0108] "Variable 'item' is defined but not in scope at this location."` at the referencing activity. Applies to the **iterator** and to anything the body introduces (a `retrieve`, a `$X = create …`, a call output) | Nothing tracked loop-variable *visibility*. MDL052 already covered the sibling rule — names are unique across the whole microflow (CE0111) — and the wording of that rule ("scoped to the WHOLE microflow") reads as if the variable is readable flow-wide. Uniqueness and visibility are different: the name is reserved everywhere, readable only inside the loop body | `mdl/executor/validate_microflow_loop_scope.go` (new `MDL053`, wired from `microflowValidator.validate`), skill `.claude/skills/mendix/write-microflows.md` | Map each loop-scoped name to the loop whose **own** body introduces it (nested loops keep their own names), then walk the flow with the set of enclosing loops and flag any reference from outside the owner. **A name claimed by two loops is dropped, not reported** — that is the MDL052/CE0111 case, and without the guard the MDL052 negative example started failing for the wrong reason: the first loop's own use of `$R` was blamed on the second loop's claim. **Generalisable**: a rule keyed by variable *name* needs an ambiguity escape hatch whenever another rule exists precisely because names can collide. Both flavours verified against mxbuild 11.12.1 (2 × CE0108 in one probe). Repro `mdl-examples/bug-tests/loop-variable-out-of-scope.fail.mdl`; tests `mdl/executor/validate_microflow_loop_scope_test.go`. Found while working the sudoku findings, but **not** one of them — the numbered finding it was filed under is an app bug in that project, not an mxcli defect | -| `mxcli oql` silently omits a whole column: a `select A, B …` renders only `A`, and the JSON output has no `B` key at all — no error, no empty column. Reproduces whenever the value of `B` is **null in the first row** | The runtime omits a null-valued column from a row's JSON object entirely, and `parseOQLFeedback` took the column list from `extractColumnOrder(rows[0])` — one row, chosen for its key *order*. Every later row was then projected onto that short list, so the column vanished from the result rather than showing NULLs | `cmd/mxcli/docker/oql.go` (`parseOQLFeedback`, `mergeColumnOrder`, `hasOnlyKnownKeys`) | Union the keys of **all** rows, inserting each new key directly after the last key already known rather than appending — merging `[A, C]` with `[A, B, C]` must give `[A, B, C]`, not `[A, C, B]`, or a column that is null early in the result set jumps to the end of the table. The per-row re-scan is skipped when a row carries no unseen key, so the uniform case still costs one length check. **Generalisable**: any "take the shape from the first element" over a sparse encoding is a silent-wrong-answer bug, not a formatting bug — the output looks complete. Tests `TestParseOQLFeedback_ColumnUnionAcrossRows`, `TestMergeColumnOrder`; proven by stubbing the union back to first-row-only and watching the reported symptom return. sudoku #39, first half | -| `mxcli test` cannot run at all in a container without a Docker daemon — parsing, runner generation, model injection and the **entire mxbuild build** succeed natively, then `docker up` fails with "failed to connect to the docker API at unix:///var/run/docker.sock". So microflow tests are unavailable in exactly the environment mxcli targets (Claude Code web containers ship `/usr/bin/docker` with no daemon) | Only one step of the run was containerised — start the runtime and read its log — but it was wired directly to `docker compose`, with no seam for another way to run the app. `run --local` had all three pieces already (boot a standalone runtime, tee its log, restore) | `cmd/mxcli/docker/localapp.go` (new `StartLocalApp`), `cmd/mxcli/testrunner/runner_local.go` (new), `cmd/mxcli/testrunner/runner.go` (docker path extracted to `runDockerAndCapture`), `cmd/mxcli/cmd_test_run.go` + `main.go` (`--local`) | Give the run a seam — `runLocalAndCapture` / `runDockerAndCapture` — so both modes share parse/inject/parse-results/cleanup and differ only in how the app is started. **Two traps, both found by running it rather than reasoning about it.** (a) The runner reports via an **after-startup** microflow, so its LOG output happens DURING the start action, before the runtime's log subscriber attaches; registering the subscriber early is not possible (the runtime answers `LoggingException` pre-start). What actually carries the output is the JVM console tee, live from spawn — verified by A/B running with the early attach removed. (b) A **failing** test makes the runner return false, which makes the after-startup action fail, which makes `start` return an error — the first version reported that as a broken run and dumped a stack trace instead of the test report. A failed boot whose log shows a verdict is a normal outcome. Local runs use their own ports (8081/8091) and a `_test` database so a `run --local` dev loop can keep serving. Verified end-to-end in a daemon-less container: 2 passed; then 1 passed / 1 failed with exit 1, project restored. sudoku #41 | -| A solution's apps are served on one host with different ports, so they share a cookie jar (cookies key on host name and **ignore the port**) — logging into one silently replaces the other's `XASSESSIONID`. Giving each app a host name in **App Settings -> Configurations -> Application root URL** appears to do nothing under `run --local` | `runtimeConfigParams` builds the entire boot `update_configuration` payload from `LocalRuntimeOptions`, and `ApplicationRootUrl` was only ever populated from a `--hub` registration. The model's own value had no path into the payload — and since the admin action REPLACES rather than merges, nothing else could supply it either | `cmd/mxcli/docker/runlocal.go` (`configuredApplicationRootURL`, `applicationRootURLFrom`, `customHostRootURL`) | Read the setting off the project at boot and use it when no hub URL was assigned (hub wins — that URL is the one actually serving the app). **The trap is that a blank Mendix app already ships `ApplicationRootUrl = http://localhost:8080/`**, so "is set" does not mean "was chosen": honouring every value would change behaviour for every existing project and, under `--app-port`, advertise a port the app is not serving on. Only a **non-loopback host** is passed through, and a port that disagrees with `--app-port` warns. Serving under the host name needs no flag at all — the runtime accepts any `Host` and the client uses relative URLs (verified: 200 via `/etc/hosts`, nip.io and localtest.me); the setting matters only for the **absolute** URLs Mendix generates. **Generalisable**: before defaulting from a model setting, check what a blank project already has in it — a non-empty default makes "fall back to the model" a behaviour change, not a no-op. Verified end-to-end on 11.12.1: with `backend.local` configured, boot prints `Application root URL from configuration "Default"` and the app answers 200 on both the host name and the listen address. Tests `TestApplicationRootURLFrom`, `TestCustomHostRootURL` | -| A page bound to an attribute the entity **inherits** (`Person extends Administration.Account`, page binds `FullName`) passes `mxcli check --references` AND `mxcli lint`, then the real MxBuild fails `[error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists."` The message reads as a deletion; it never existed there | Mendix stores a page's attribute reference against the entity that **declares** the attribute. `resolveAttributePath` qualified a bare name with `pb.entityContext` unconditionally, so a specialization that merely inherits the attribute got a dangling reference. Two independent resolvers had the same bug: the direct binding, and the final attribute of an association path (`resolveAssociationAttributePath`) | `mdl/executor/cmd_pages_builder_input.go` (`declaringEntityFor`, `entityAttributeOwners`), `mdl/executor/cmd_pages_builder_v3.go` (final attribute of the association path) | Walk the generalization chain and qualify with the first entity that declares the name. **Fix both resolvers** — a probe that only tested the direct case would have shipped half a fix; the reporter's own table already showed the associated case failing, and it did still fail after the first patch. Unknown names keep today's context qualification rather than being re-pointed, and a cyclic chain terminates. **Watch the new dependency**: attribute resolution now consults the domain models, and `getDomainModels` panics on a nil backend — several unit tests build a `pageBuilder` with neither backend nor cache, so the lookup bails out early instead. A/B on Mendix 11.12.1: pre-fix binary → CE1613 for the inherited column and 0 errors for the own column; fixed binary → 0 errors for both shapes. Repro `mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl`; tests `mdl/executor/cmd_pages_builder_inheritance_test.go`. mxcli-todo #12 | -| The SessionStart hook `mxcli init` writes cannot survive an idle reap: it is guarded on `test -x ./mxcli`, and `.gitignore` excludes that binary (~85 MB) on purpose. The container is reclaimed, the repo re-cloned without it, the guard fails and the hook no-ops through `\|\| true` — the next session has no mxcli, no MxBuild cache, no database, and no message saying so | The hook inlined the whole bring-up in one shell line, so "binary missing" could only be expressed as "skip". A hook line cannot reasonably do OS/arch detection and a download; something committed has to | `cmd/mxcli/init_hook.go` (`bootstrapScriptTemplate`, `writeBootstrapScript`, `sessionStartHookCommand`, `sessionStartHookMarkers`) | Emit a committed `.claude/bootstrap-mxcli.sh` that resolves OS/arch, fetches the binary when absent (`MXCLI_TAG` to pin), then runs the setup; the hook becomes `sh .claude/bootstrap-mxcli.sh \|\| true`. **Changing the hook command breaks dedupe**, which matched on the old command string — so `addSessionStartHook` now recognises *any* known marker and **rewrites the entry in place**, migrating an old project instead of leaving it with two hooks that both run. **Generalisable**: a guard whose condition is something you deliberately do not commit is a silent no-op waiting for a fresh clone — make the guard able to satisfy itself. Verified by reproducing the reap: moved `./mxcli` out of the project, ran the hook command verbatim, watched it re-download (88 MB, new mtime) and finish with "Setup complete … database ready". Tests `TestAddSessionStartHook_MigratesLegacyCommand`, `TestEnsureSessionStartHook_WritesFile`. mxcli-todo #2 | -| `mxcli exec -p app.mpr - <<'EOF' … EOF` fails with `Error reading file: open -: no such file or directory` — `-` is taken literally as a filename, so MDL cannot be piped or written as a heredoc and every ad-hoc script needs a temp file first | `exec` (and `check`) called `os.ReadFile(path)` directly, with no case for the conventional stdin spelling | `cmd/mxcli/mdlsource.go` (new `readMDLSource`, `mdlSourceLabel`), `cmd/mxcli/cmd_exec.go`, `cmd/mxcli/cmd_check.go` | One helper both commands share, so `check` gained the same spelling rather than only the reported one; `check` reports the source as `` instead of a bare `-`. Verified live: a heredoc through `exec` and a pipe through `check` both run. Tests `cmd/mxcli/mdlsource_test.go`. mxcli-todo #5 | -| `mxcli syntax` documents spellings the parser rejects, so an agent following the reference writes MDL that fails — `TEXTBOX … (Binds: Attr)` ("'Binds:' is no longer supported, use 'Attribute:' instead") and `DataSource: MICROFLOW Module.MF()` (a zero-arg microflow datasource takes NO parens, unlike RETRIEVE/CALL) | Nothing checks the `syntax` corpus against the parser. `make check-skill-mdl` validates MDL blocks in the skills and the docs site, but the `Syntax`/`Example` strings in `cmd/mxcli/syntax/*.go` are not covered, so a retired spelling can sit there indefinitely | `cmd/mxcli/syntax/features_page.go` (10 × `Binds:` → `Attribute:`, the datasource parens), `cmd/mxcli/syntax/retired_spellings_test.go` (new guard) | Fix the text **and** pin it: a table-driven test fails if a retired spelling reappears in any topic's Syntax or Example. It is a spelling guard rather than a parse — the snippets are fragments (a DATAVIEW body, a property line) that do not stand alone as statements, so they cannot just be fed to the parser. Proven by reintroducing `Binds:` and watching the test name the topic and field. **A third claim in the same report did not reproduce**: `CONTAINER (OnClick: SHOW_PAGE M.P(Param: $currentObject))` parses fine on current main, so only the two verified ones were changed. mxcli-todo #8 | -| `mxcli test … --local` (or any other `StartLocalApp` caller) fails with MxBuild's `the project file path should be an absolute path`, followed by a page of Windows sample requests, whenever `-p` is given a **relative** path | `ServeServer.Build` forwarded `ProjectFilePath` verbatim. `mxcli run` had learned to absolutize at the CLI layer (findings #17), but that fix lived in `cmd_run.go`, not in the code that talks to MxBuild — so the next caller re-hit it | `cmd/mxcli/docker/mxserve.go` (`ServeServer.Build`) | Absolutize `req.ProjectFilePath` in `Build` itself, the single place that talks to MxBuild, so no future caller can miss it; also resolve `LocalAppOptions.ProjectPath` in `applyDefaults` so `DeployDir` and the runtime log path are not derived from a relative value. Test by pointing a `ServeServer` at an `httptest` fake and asserting on the request body — the CLI-layer fix cannot be tested that way, which is part of why it did not generalise | -| `mxcli test --attach` fails with `reload_model failed: Authentication failed.` — after the test microflows have already been injected into the project | The M2EE admin API and the test endpoint are **different secrets**. `attach` built its `RuntimeController` with `M2EEOptions{Token: hs.Token}` — the endpoint token — instead of the runtime's admin password | `cmd/mxcli/testrunner/runner_attach.go` (`attach`), `cmd/mxcli/testrunner/handshake.go` (`Handshake`) | Carry `AdminPass` in the handshake alongside `Token` and pass that to `M2EEOptions`. The hosting `run --local` publishes it via `docker.LocalAppInfo` (the resolved value, not the package default, so a `--admin-pass` override still works). Whenever one process drives another's M2EE API, check which credential is being passed — `defaultLocalAdminPass` and any app-level token are unrelated | -| `alter page … set Editable = [expr]` (or `set Visible`) writes a project Studio Pro refuses to open: `StorageLoadException: Conditional editability settings has an invalid value '' for property Attribute`. `mxcli check` ✓ and `mx check` ✓ — neither inspects the stored value. The identical settings written by `create page` load fine | The ALTER path builds the `Forms$Conditional{Visibility,Editability}Settings` node by hand and wrote `Attribute: null`. `Attribute` is a **BY_NAME** `AttributeIdentifier`, so its unset value is the empty string, not null — exactly what the CREATE path already encodes via `codec.RegisterTypeDefaults(..., EmptyStringFields: []string{"Attribute"})`, whose comment records this same StorageLoadException from #627. Only the hand-built ALTER node missed it | `mdl/backend/pagemutator/mutator.go` (`setWidgetConditionalSettingMut`) | Write `{Key: "Attribute", Value: ""}`, not `nil`. **General rule: when one path hand-builds BSON that another path builds through the codec, diff the two encodings rather than eyeballing the hand-built one** — `mxcli bson dump --type page --object M.P` on a CREATE-authored and an ALTER-authored widget makes the divergence a one-line diff (key sets and values were otherwise identical). `SourceVariable` stays `nil`: it is BY_ID, where null *is* the absent value, so "null is wrong" is per-field, not a blanket rule. Test `TestSetWidgetConditionalSetting_AttributeIsEmptyString`; repro `mdl-examples/bug-tests/851-alter-page-conditional-attribute.mdl`. Issue #851 | -| A widget conditional using a function whose name is also an MDL lexer keyword — `visible: [trim($currentObject/Slug) != '']`, `[length(…) > 0]`, `empty`/`count`/`find` — **silently drops the whole property**; `mxcli check` ✓, `mx check` ✓, and the widget renders unconditionally visible. `toUpperCase`/`isMatch`/`contains` in the same position work | `xpathFunctionName` (MDLPage.g4) enumerated only `IDENTIFIER \| HYPHENATED_ID \| NOT \| TRUE \| FALSE \| CONTAINS`, so `trim(` never matched `xpathFunctionCall`. The enclosing `[...]` then failed to parse as an `xpathConstraint` and matched the generic `propertyValueV3` alternative instead, so the visitor set `Visible` (an array) rather than `VisibleIf`, and the builder's `else if pages.StaticVisibleExpression(...)` — which reads only bool/string — never fired | `mdl/grammar/domains/MDLPage.g4` (`xpathFunctionName`) + `mdl/executor/validate_widgets.go` (`validateConsumableConditional`) | Define `xpathFunctionName : xpathWord \| NOT` — `xpathWord` is a negated token set, so it self-maintains as the lexer gains keywords; an enumerated list reacquires this bug with the next promoted function name. Safe because `xpathFunctionCall` requires a following LPAREN and no `xpathStepValue` may be followed by one, so bare `empty` still parses as a path word. `NOT` is spelled out (xpathWord excludes it). **Also add the general guard**: MDL-WIDGET19 errors when `Visible`/`Editable` holds a value that is neither routed to `VisibleIf`/`EditableIf` nor a bool/string — that is the residue signature of any conditional the visitor could not build, so the next one fails loudly instead of vanishing. `make grammar` regenerates the parser (not committed). **Verify in a browser, not at `mx check`** — a dropped property is still a valid model, so `mx check` reports 0 errors before AND after; the symptom only exists at render time (see `verify-in-runtime.md`). The repro script carries a `Bug852.Verify` page for this: `Slug` is three spaces, so `trim()` changes the outcome and a dropped `Visible` renders (Mendix defaults to visible). Pre-fix all 5 markers render; post-fix only the 3 that should. **One rule, two contexts**: `xpathConstraint` serves both `Visible:`/`Editable:` (a Mendix *client expression* — trim/length/toUpperCase/find) and a datasource `where` (real *XPath* — contains/starts-with/ends-with/string-length/not, `length()` = list length, aggregates Java-only, and `empty`/`NULL` are KEYWORDS not calls). The sets differ, so the grammar must not enumerate either; mxbuild adjudicates. Regression-test the XPath side when touching this rule — `[Name = empty]`, `[Name = NULL]`, `not()`, `contains()`, `starts-with()`, `string-length()` all still parse and `mx check` clean. Tests `TestConditionalVisibility_KeywordFunctionNames`, `TestValidateStaticWidget_UnconsumableConditional`; repro `mdl-examples/bug-tests/852-conditional-keyword-functions.mdl`. Issue #852 | -| `download file $Doc;` is accepted by `mxcli check` and `mxcli exec` ("Created microflow") but the activity lands with **no action at all** — `describe` renders `-- Empty action` and `mx check` fails `[CE0008] "No action defined."`. Same for `download file $Doc show in browser;` | `microflowActionToGen` (the modelsdk write path) had no `*microflows.DownloadFileAction` case, so it hit `default: return nil` and the enclosing ActionActivity was serialized with a nil Action. Grammar, visitor, flow builder, read path and DESCRIBE formatter were all already in place, so the statement passed every stage that reports anything and vanished at the one that does not | `mdl/backend/modelsdk/microflow_write.go` (`microflowActionToGen`) | Add the case, setting `FileDocumentVariableName`, `ShowFileInBrowser` and `ErrorHandlingType` (Rollback default). **The storage key is `ShowFileInBrowser`, not `ShowInBrowser`** — the gen setter binds the right one; legacy's `parseDownloadFileAction` reads the wrong key. **Test at the round trip, not the reader**: a reader-only test starts from BSON the writer never had to produce, so `TestActionFromGen_DownloadFile` was green throughout. `roundTripMicroflow` (model→gen→codec→model) is the harness; assert the ActionActivity's `Action` is non-nil, which is the CE0008 shape itself. This is the same silent-drop mechanism as the `microflowObjectToGen` default branch (#791) — when auditing, diff the write switch's cases against `sdk/mpr/writer_microflow_actions.go`. Test `TestMicroflowRoundTrip_DownloadFile`; repro `mdl-examples/bug-tests/850-download-file-action.mdl`. Issue #850 | -| A decision written with **uppercase** keywords — `IF $T/Status != M.Status.Done AND $T/CompletedOn != empty` — passes `mxcli check` and then fails the build with `[error] [CE0117] "Error(s) in expression."`, quoting the expression back with `AND` still uppercase. The same condition written with `=` builds fine, which makes it look like `!=` cannot be an operand of `AND` | Mendix requires its word operators lowercase. A rebuilt `BinaryExpr` gets `strings.ToLower(e.Operator)`; a condition kept as an `ast.SourceExpr` (original text **plus** the parsed tree) returned `e.Source` verbatim and skipped it. The `=` form parses to a BinaryExpr and the `!=` form to a SourceExpr — hence the operator-shaped illusion | `mdl/executor/cmd_microflows_helpers.go` (`normalizeMendixOperatorCase`, applied in the `SourceExpr` branch) | Lowercase `and/or/not/div/mod` in preserved source, leaving everything else byte-identical: a scanner that tracks single-quoted literals (with `''` escapes) and skips any word preceded by `.`, `/` or `$`, so `'AND'`, `M.Enum.And`, `$Task/Mod` and `$Android` are untouched. **The reporter's own probe table is the cautionary bit** — nine builds established a rule ("any `!=` inside `AND` fails") that was real in every observation and wrong about the cause, because every failing probe was uppercase and the control was not. When a table's rule tracks a token, check what ELSE differs between the rows. Reproduced and fixed against mxbuild 11.12.1: stored `AND` → CE0117, stored `and` → 0 errors. Repro `mdl-examples/bug-tests/todo-14b-uppercase-and-operator.mdl`; tests `TestNormalizeMendixOperatorCase`. mxcli-todo #14b | -| `ALTER ENTITY X ADD EVENT HANDLER …` errors when the handler exists and `DROP EVENT HANDLER …` errors when it does not, so a script containing either cannot be re-run — and a defensive drop-then-add fails on whichever half does not match. `ADD ATTRIBUTE` has `IF NOT EXISTS`; event handlers had no equivalent | The idempotency guards added for attributes (findings #10) were never extended to the event-handler clauses, which are the one member with no other re-run route | `mdl/grammar/domains/MDLDomainModel.g4` (`ifNotExists?` / `ifExists?` on the event-handler clauses), `mdl/visitor/visitor_entity.go`, `mdl/executor/cmd_entities.go` | Reuse the existing `ifNotExists`/`ifExists` grammar rules rather than inventing a second spelling, so the guard reads the same everywhere. The error messages now name the flag, so the fix is discoverable from the failure. Verified by running the same script twice: first run drops (skipping, absent) then adds; second run skips both, exit 0, and the project still builds 0 errors. mxcli-todo #18 | -| `CREATE DEMO USER` reports success and `SHOW PROJECT SECURITY` reports `Demo Users Enabled: true`, yet the running app has **zero** accounts — `SELECT Name FROM Administration.Account` returns 0 rows, there is no login page, and none of the row-level XPath rules are enforced | A blank mxcli template ships with **Security Level: Off**, and with it off the runtime creates no accounts at all. The demo users are written to the model correctly; nothing connected the two facts, so the model said yes and the app said nothing | `mdl/executor/cmd_security_write.go` (`warnDemoUsersInert`, called after a successful create) | Say it at the moment the user would otherwise believe it worked, and name the one statement that fixes it (`alter project security level prototype`). A *warning*, not a refusal: authoring demo users before raising the level is legitimate ordering. **Generalisable**: when a write succeeds but a project-level setting makes it inert, the write path is the only place with both facts in hand. Tests `TestWarnDemoUsersInert`. mxcli-todo #15 | -| Wiring a microflow that takes parameters to a **BEFORE CREATE** event handler passes `mxcli check` (and `--references`), and the build then fails `[error] [CE7247] "Microflow should not have parameters" at Event handler of entity …` | Mendix passes no object to a before-create handler — the object does not exist yet — so the handler is called with no arguments. Nothing compared the handler's moment against the microflow's signature; the pairing is only invalid for this one moment/event combination | `mdl/executor/cmd_entities.go` (`checkBeforeCreateHandlerHasNoParameters`, called from `buildEventHandlers`) | Guard where the two paths converge — `buildEventHandlers` is shared by `CREATE ENTITY`'s inline handlers and `ALTER ENTITY ADD EVENT HANDLER`, so one check covers both. It refuses **before the model is written**, and the message carries the build code plus the way out (AFTER CREATE, which does receive the object). **A microflow created earlier in the same script is not readable back yet, so an unreadable microflow is skipped rather than refused** — mxbuild still catches the real case, and failing on the read would break legitimate scripts. Note this is an exec-time guard: `mxcli check` without a project cannot see the microflow's signature at all. A/B on 11.12.1: pre-fix binary writes it and mxbuild reports CE7247; fixed binary refuses, and both AFTER CREATE and a no-parameter BEFORE CREATE still work. Tests `mdl/executor/cmd_entities_before_create_test.go`. mxcli-todo #14a | -| "Contrast is low and not everything uses the dark theme" — and switching theme does not help, because `signal`, `ledger` and `console` all render the same defects. The worst of it is the **login page**: Atlas's stock photograph fills half the viewport on a dark app, and the Sign in button is green while the app's primary button is the brand colour | The login page is served from `theme/web/login.html`, which loads the SAME compiled theme CSS (`{{themecss}}`) — so it IS themeable, but nothing themed it. Two Atlas rules do the damage: `.loginpage-image` layers a brand-tinted gradient over `url("./resources/work-do-more.jpeg")`, and the submit button is `.btn-success`, so it follows the **success** colour rather than the brand. Separately, `--link-color` was mapped straight to the brand, and console's light-variant teal is 3.74:1 on white — under AA for body text | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-atlas-map.scss` (login block + `--mxt-link` indirection, all three copies stay byte-identical), `cmd/mxcli/theme/assets/console/.../_mxcli-console.scss` (light `--mxt-link`) | Replace the photo with a token-built gradient and point the login button at the brand; give link TEXT its own `--mxt-link` token defaulting to the brand, because a link needs 4.5:1 as text while a brand used as a button FILL only needs 3:1 plus contrast against its own ink — darkening the brand everywhere would have been the wrong lever. Console light gets `#0f766e` (5.47 / 5.10 / 4.92 against surface / ground / surface-alt). **The project's own `theme/web/logo.png` is deliberately left alone** — it is the app's asset to replace, and hiding it would strip a real logo from apps that have one. **Verification boundary, stated plainly**: verified at the compiled-CSS layer (the overriding `.loginpage-image` is last and carries no photo; the button and link declarations resolve), NOT in a browser — raising the scratch project's security level to serve a login page surfaced pre-existing model errors there that block deploy. The `#999` empty-state label could not be reproduced: every `#999` in the compiled CSS is a Bootstrap default (popover arrows, modal border, print styles), so that one needs the reporter's app. mxcli-todo #19 | - -| A wrong `icon:` reference passes `mxcli check` (including `--references`) and first surfaces as a **build** error: `[error] [CE1613] "The selected custom icon 'Atlas_Core.Atlas_Filled.no-such-icon' no longer exists." at Action button 'btnBad'` | Nothing resolved the reference — the name was written straight through to BSON. Icon-collection lookup existed only for `show`/`describe` (`cmd_iconcollections.go`), never in a validation path | `mdl/executor/validate_icon_refs.go` (`validateIconRefs`), wired into `validateProgram` | Index the project's icon collections once per run and resolve every reference in the program. **Put it in the `--references` pass, not the no-project one** — the collections are documents *in the project* (a blank 11.13 app ships three, ~770 icons), so unlike the #836 grant check there is genuinely nothing to resolve against without `-p`. **Report the two failures differently**: an unknown *icon* in a known collection gets near-match suggestions plus `describe icon collection `; an unknown *collection* gets the list of collections that exist, because that is where the typo usually is. **Generalisable — the shape to look for**: a string property that names a model element but is stored as a plain string is invisible to every reference checker; grep for properties whose value is a qualified name yet whose type is `string`. **Test the silence, not just the noise**: the risk in a new check rule is false positives, so sweep the repo's own examples (all 9 `mdl-examples` scripts using icons) before shipping — a rule that fires on valid input is worse than the gap it closes. **Repro cannot be a `.fail.mdl`**: `make check-mdl` runs `mxcli check` with no `-p`, so a bad-icon script would pass there and be reported as a negative test unexpectedly passing; the repro carries valid icons and the rejection cases live in unit tests. Repro `mdl-examples/bug-tests/icon-reference-validation.mdl`; verified end-to-end (bad reference reported before any write; `mx check` 11.13.0 0 errors on the valid script) | -| `ALTER PAGE … SET Action = microflow M.F ON btn` does not parse — `extraneous input 'M' expecting {DROP, ADD, SET, INSERT, REPLACE, '}'}`. The documented workaround is `REPLACE`, which works but silently drops every property the statement does not restate (ButtonStyle, Class, design properties, tooltip) | `alterPageAssignment` special-cases `DATASOURCE`, `VISIBLE` and `EDITABLE`, then falls through to `identifierOrKeyword EQUALS propertyValueV3` — and `propertyValueV3` has no `microflow ` form, so the value position could not hold an action at all. `CREATE PAGE` has had `ACTION COLON actionExprV3` all along | `mdl/grammar/MDLParser.g4` (`alterPageAssignment` gains `ACTION EQUALS actionExprV3`), `mdl/visitor/visitor_alter_page.go` (`buildAlterPageAssignment`), `mdl/executor/cmd_alter_page.go` (`convertASTAction` + routing), `mdl/backend/mutation.go` + `pagemutator/mutator.go` + `mock/` + `mcp/` (`SetWidgetAction`) | Reuse `actionExprV3` — the same rule CREATE PAGE uses — and build through the **CREATE PAGE builder** (`pb.buildClientActionV3`) rather than a second switch. **Generalisable — the shape to look for**: when `SET` and `REPLACE` (or any narrow/wide pair) can express different vocabularies for the same property, the narrow one is a whitelist that will be extended one bug report at a time — #855 was the identical bug for `DataSource`, filed separately. Delegate instead of enumerating, and the pair cannot drift. **Guard-don't-drop**: refuse `SET Action` on a widget with no `Action` property rather than writing it — Studio Pro resolves every stored property against the type's property list and throws, while mxbuild tolerates the unknown key, so a silent write **builds clean and fails to open**; the build is not a safety net. **Measurement trap**: reverting only the grammar rule does not produce a failing test, it produces a *compile* error (the visitor references `ctx.ActionExprV3()`), so prove causation by reverting grammar+visitor together and re-running the original statement through the CLI. **Do not be misled by CE1571 on a `SHOW_PAGE` action** — authoring the same action through CREATE PAGE reproduces it exactly, so it is the builder's `$currentObject` auto-binding outside a dataview, not this change; always author the control before blaming the new path. Note `OPEN_LINK` still refuses on the modelsdk engine (`LinkClientAction` is unsupported by the codec) — also pre-existing, and CREATE PAGE refuses it identically. Repro `mdl-examples/bug-tests/alter-page-set-action.mdl`; verified end-to-end (Mendix 11.13.0, `mx check` 0 errors, `Class` survives four retargets without being restated) | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before @@ -405,368 +162,5 @@ extracting `OffsetExpression`/`LimitExpression`. - [ ] Failing test written before implementation - [ ] `go test ./mdl/executor/... ./sdk/mpr/...` passes -- [ ] New symptom row added to the table above (if not already covered) +- [ ] New finding appended to `findings/.jsonl` (if not already covered), and `make check-findings` passes - [ ] PR title: `fix: ` -| A login that is known to be correct starts failing on `run --local` — the page says only **"Sign in failed"**, as if the password were wrong. `--screenshot-user` silently produces screenshots of the login page instead of the requested page | The local runtime is unlicensed, and an unlicensed runtime caps concurrent sessions. Past the cap it refuses the sign-in and logs `Maximum number of sessions exceeded! (You are currently using a trial license)` to `.mxcli/runtime.log` only; the page reports the refusal identically to a bad password. mxcli's own login helper never noticed either — it filled the form, waited, and saved whatever session it had | `cmd/mxcli/docker/screenshot_login.go` (`loginScript` failure detection, `loginFailureHint`, `readLogTail`), `cmd/mxcli/docker/runlocal.go` (passes `RuntimeLogPath`), `.claude/skills/mendix/run-local.md` | Not an mxcli defect in the model layer — but mxcli held both halves and joined neither. Mendix answers a rejected sign-in by re-rendering the same form, so **the username field still being present after the click is the signal** that login did not complete; on that signal read the tail of the runtime log and name the cap. **Generalisable**: when a browser-driven helper "succeeds" with a degraded result, look for a server-side log that already says why — the page is often the least informative witness. Sessions are released by restarting `run --local`; a browser-driving script should sign out at the end, or the fifth or sixth run is the one that fails and it looks like a regression. Tests `TestLoginFailureHint`, `TestReadLogTail`. mxcli-todo #16 | -| A fresh clone of a project created by `mxcli new` goes dirty the first time anyone builds it: ~50 **tracked** files modified that nobody edited — every `javascriptsource/*/actions/*.js` gains a banner, `import { Big } from "big.js"` and `export async function`, plus the matching `javasource` stubs. In a cloud session with a stop-hook git check it reads as "uncommitted changes" at the end of clean work | The template ships the generated action stubs in a slightly older shape and MxBuild rewrites them all on the first build. `mx check` does **not** — only a build does — so nothing before the first `run --local` could reveal it, which is after the user has already committed | `cmd/mxcli/docker/settle.go` (`SettleGeneratedSources`), `cmd/mxcli/cmd_new.go` (step 5/6, `--skip-build`), `cmd/mxcli/init.go` (`/theme-cache/` in the generated ignore list) | Fix the *timing*, not the content: run the build while the project is still being created, so the settled form lands in the first commit. Do **not** reimplement the rewrite — it is mxbuild's generator and version-specific; run the real thing. Best-effort by contract (no JDK, no mxbuild, failed build → warning, never a failed creation), because a settled tree is a nicety and a usable project is the deliverable. The other half is gitignore: `theme-cache/` is a cache and says so. A/B on 11.12.1, both git-init'd then built: `--skip-build` → 50 dirty files, default → **0**. Tests `cmd/mxcli/docker/settle_test.go`. mxcli-todo #7 | -| `mxcli check` reports `✓ Syntax OK` / `Check passed!`, then `mxcli exec` on the same script fails partway through with `failed to resolve page: page not found: Module.Page` — a button targeting a page the script creates further down. `exec` is not transactional, so the statements before the failure are already written to the .mpr | Page references are resolved in statement order at exec time. `check --references` already had an ordered pass (`validateForwardPageRefs`), but it needs `-p`; plain `check` had nothing, and plain `check` is what gets run | `mdl/executor/validate_page_order.go` (`ValidateScriptPageOrder`, MDL-PAGE01), wired in `cmd/mxcli/cmd_check.go` beside the other project-free validators | The soundness argument is what makes this work without a project: a **plain** CREATE later in the script would fail if the page already existed, so the script itself asserts the page does not exist yet and the earlier reference cannot resolve against the project either. `CREATE OR MODIFY`/`OR REPLACE` assert nothing, so they stay with `--references`, which can look. **Generalisable**: an ordering rule that seems to need project state often does not, once you find the statement that already asserts what you were going to look up. Two things the diagnostic must say and does: a cycle cannot be fixed by ordering (create one page without the linking widget, add it with `ALTER PAGE … INSERT`), and commit before executing a large script. Verified against all `mdl-examples/**/*.mdl` for false positives (0). Tests `mdl/executor/validate_page_order_test.go`, example `mdl-examples/bug-tests/todo-9-forward-page-reference.fail.mdl`. mxcli-todo #9 | -| A sidebar navigation label renders truncated — "All tasks" shows as "All task" — under every theme (`signal`, `ledger`, `console`) and both variants, so switching theme does not help. Measured on the live DOM as `scrollWidth=56` inside `clientWidth=48` | Atlas's **closed** sidebar is an icon rail: `--navsidebar-width-closed: 48px` in Atlas's own `themesource/atlas_core/web/themes/_theme-default.scss`. The label is wider than the rail, and the closed rail expects an *icon*, not text. No mxcli theme sets any navigation width — the themes map colours — which is exactly why every theme reproduces it | `.claude/skills/mendix/theme-styling.md` (documented; no code change) | **A fix was written, verified, and reverted** — record the reason: adding `text-overflow: ellipsis` to the nav item helps only where Atlas also sets `white-space: nowrap`; elsewhere the label wraps to two readable lines, and the rule turns `All / tasks` into `All / t…`. Screenshotted both ways against the real compiled CSS. The answer belongs to the app (give nav items icons — what the rail is for — or keep the sidebar open), not to a theme that would impose it on every app. **Generalisable**: when a reported symptom traces to an upstream layout constant, reproduce the geometry against the real compiled CSS (48px container, real class names, Playwright measurement) — it takes minutes, tells you whose constant it is, and shows when the "obvious" CSS fix is a regression. mxcli-todo #19d | -| `create non-persistent entity X ( A: String not null error '…' )` (or `unique`) passes `mxcli check` AND `mxcli exec`, then the build fails **CE0070** "Validations rules are not allowed on entity 'X', because it is not persistable" | `not null` / `unique` ARE validation rules — Studio Pro models "required" and "uniqueness" as rules on the entity, not as column constraints — so Mendix rejects them on a non-persistable entity. Nothing in mxcli connected the attribute constraint to the entity's persistence kind | `mdl/executor/cmd_enumerations.go` (`validateNPEValidationRules`, called from `ValidateEntity`) | Add **MDL054**, error severity, fired from the CREATE path only. **Establish the construct matrix against mxbuild before writing the rule, not from the issue text** — verified on 11.6.6 that `not null` with a message, `not null` bare, AND `unique` each produce CE0070 while a plain attribute does not, so the bare form (easy to miss, since the reporter only showed the message form) is flagged too. The CREATE path is the only one that can run this: `ALTER ENTITY … ADD ATTRIBUTE` does not carry the persistence kind, the same limitation MDL020 has. Sweep `mdl-examples/` + `scripts/check-skill-mdl.sh` after adding any error-severity rule — a false positive there breaks every user with that shape. Negative test `mdl-examples/bug-tests/832-npe-validation-rules.fail.mdl` (`.fail.mdl` = must fail check, enforced by `make check-mdl`) plus `-ok.mdl` pinning the other edge; tests `TestValidateEntityNPEValidationRules`. Issue #832 | -| `retrieve $L from Mod.Entity where [Attr = $Var/Mod.Assoc/Attr]` passes `mxcli check` AND `mxcli exec`, then the build fails **CE0161** "Error(s) in XPath constraint" | Mendix XPath reaches at most ONE hop off a variable, and nothing checked the hop count | `mdl/executor/validate_microflow.go` (`checkXPathVariableTraversal`, `xpathVarTraversalRe`), called from the `*ast.RetrieveStmt` arm beside MDL047/MDL048 | Add **MDL055**, error severity, matching a `$var`-rooted path with **2+ segments**. **Establish the boundary against mxbuild first — it is narrower than it looks**: `$Var/Attr` VALID, `$Var/Mod.Assoc` VALID (one hop to the associated object), `$Var/Mod.Assoc/Attr` CE0161. A rule keying on "a module-qualified segment follows a variable" would reject the middle form, which is legal; key on hop count instead. Verified on 11.6.6 by building all three and dropping the offender to confirm the other two are clean. **Reject, don't try to serialize** — there is no valid XPath for the two-hop form, so the constraint must be restructured and only the author knows which way. **Verify the suggestion you emit**: both recommended rewrites (`retrieve $Related from $Var/Mod.Assoc;` then constrain on `$Related/Attr`; or invert to `[Mod.Assoc/Mod.Entity = $Var]`) were built and confirmed at 0 errors before the message claimed "both forms build clean". Negative test `mdl-examples/bug-tests/831-xpath-variable-traversal.fail.mdl` + `-ok.mdl`; test `TestXPathVariableTraversal`. Issue #831 | -| `mxcli check` reports a microflow rule (e.g. MDL048 `[id = $StringVar]`) but `mxcli exec` writes the same script silently — a script that skips `check` produces a project the build rejects | Two different validators. The exec path called `ValidateMicroflowBody` (semantic errors); the MDL0xx rule set lives in `ValidateMicroflow`, which was wired only into `cmd/mxcli/cmd_check.go` and the LSP. Same shape as #836, where a guard existed on every exec path but was never reached from validate | `mdl/executor/validate.go` (`validateMicroflowRules`, `execEnforcedMicroflowRules`) called from `mdl/executor/cmd_microflows_create.go` — the handler, mirroring `ValidateEntity` in `cmd_entities.go` (NOT `validateWithContext`, which would double-report under `check --references`) | Promote an **explicit allowlist** of rules verified against a real mxbuild, never the whole set. **Blanket promotion was tried and reverted**: it turns every error-severity rule into a write barrier, and `MDL009` ("enumeration splits require exactly one value per branch") was a **false positive** — a multi-value branch covering every enum value builds at 0 errors on 11.6.6, and the shipped `write-microflows` skill documents that very form (since retired, see the MDL056 row). It also broke an existing repo test whose fixture uses `else` on an enum split. `MDL008` by contrast IS correct (mxbuild: CE0079 per uncovered value + CE0773) — so before promoting any rule, build its construct and read the verdict. Warnings are never promoted. Tests `TestValidateMicroflowRules_ReachedFromExec` and `…_UnverifiedRulesNotPromoted` (the latter fails if the allowlist is widened carelessly). Issue #833 | -| `mxcli check` errors **MDL009** "enumeration splits require exactly one value per branch" on `when Open, Pending then` — but Mendix accepts it, so check rejects valid MDL and contradicts the shipped `write-microflows` skill | The rule asserted the opposite of the platform's behaviour. Nobody had built the construct: a multi-value branch covering every value **plus `(empty)`** builds at 0 errors on 11.6.6 | `mdl/executor/validate_microflow.go` (the `*ast.EnumSplitStmt` arm; `checkEnumSplitEmptyBranch`) | Retire the assertion and replace it with what actually fails: an enum split needs an outgoing flow per condition value, so a missing branch is **CE0079**. **MDL056** checks the `(empty)` branch — universal, verified to hold even on a `not null` enum attribute, so it needs no enumeration lookup and works from the statement alone. Full value coverage (the other half of CE0079) is deliberately NOT implemented: it needs the enum's member list, i.e. resolving the split variable's type against script or project, which `ValidateMicroflow` cannot see — guessing would trade one false positive for another. **Use a NEW rule ID rather than repurposing**, so anything citing the old number still means the old, wrong thing. `MDL008` (no `else`) is correct and stays — mxbuild gives CE0079 per uncovered value **plus** CE0773 on the else flow. Fix the skill in the same change: it documented the invalid `else` form. Tests `TestValidateMicroflow_EnumSplitMultipleValuesAllowed` / `…RequiresEmptyBranch`; repro `mdl-examples/bug-tests/mdl009-enum-split-empty-branch.fail.mdl` + `-ok.mdl` | -| A microflow using `split type` writes a project mxbuild cannot **load**: `KeyNotFoundException: The given key '' was not present in the dictionary` at `StreamingBsonUnitReader.ResolvePostponedProperties`. `mxcli check` ✓ and `mxcli exec` ✓; reproduced on 11.6.6 and 11.13.0 | Two gaps in the modelsdk writer, both the #791 shape. (1) `microflowObjectToGen` had no `*microflows.InheritanceSplit` case → `default: return nil`, so the split was dropped while three sequence flows kept pointing at its `$ID`. (2) `caseValueToGen` had no `InheritanceCase` case → every branch degraded to a bare `Microflows$NoCase`, losing the entity it selects on. Its value-receiver normalisation also omitted the type, so a pointer-only fix would still miss half the calls | `mdl/backend/modelsdk/microflow_write.go` (`microflowObjectToGen`, `caseValueToGen`) — mirror `sdk/mpr/writer_microflow.go` | Add both cases. **Diagnose with the #791 recipe**: `mxcli bson dump --type microflow`, collect every `$ID`, check each key ending in `Pointer` resolves (before: 27 objects / 3 dangling; after: 28 / 0). **Take field lists from the GENERATED type, not from legacy** — legacy writes `ErrorHandlingType` on the split but `initInheritanceSplit` has no such property, i.e. legacy writes a field Mendix does not define. **When adding a case-value type, update the value-receiver normalisation too.** Modelling rules confirmed on both versions while verifying: a type split needs an outgoing flow for every type INCLUDING the base (CE0090), and an `else` does NOT substitute for the base-type case. Tests `TestMicroflowRoundTrip_InheritanceSplit`, `TestCaseValueToGen_InheritanceCase{,ValueReceiver}`; repro `mdl-examples/bug-tests/split-type-dangling-pointer.mdl` | -| The `split type` docs and examples teach a shape that fails the build: `case Spec` + `else`, with no branch for the base entity → **CE0090** "The 'X' value should be configured for an outgoing flow". `mxcli check` passes, so the drift survived; `mdl-examples/bug-tests/365` and `475` both shipped it, and 475's own header claimed "mx check reports 0 errors" | `else` on an inheritance split serializes as `Microflows$NoCase` and IS accepted, so it looks like it covers the remainder — but it does not satisfy type coverage. The base entity needs its own `case` | `.claude/skills/mendix/write-microflows.md` (Type Split section) + `mdl-examples/bug-tests/365-…`, `475-…` | Cover EVERY type including the base; `else` is then redundant. Also give the split somewhere to go: branches converge on a merge continuing to the end event, so a non-void microflow needs a `return` after `end split;` (else MDL003 + **CE0067**). Matrix verified on 11.6.6 AND 11.13.0: `specs+base` 0 errors, `specs+base+else` 0 errors, `specs+else only` CE0090. **When repairing a bug-test fixture, preserve the scenario it pins** — 475 tests "exactly ONE non-split branch continues", so its added base case must TERMINATE; an empty (falling-through) body would make two branches continue and silently retire the regression. Confirmed after the edit that the post-split activity still renders outside both case bodies and the describe→exec roundtrip is mxbuild-clean. Known cosmetic artifact: DESCRIBE emits an empty `else` block that was never authored; it re-parses and builds clean | -| A published OData service created purely from MDL passes `mxcli check` and then fails the build — `[CE0729] "The service name should not be empty."` and `[CE7375] "Attribute ID for entity 'X' must be published and be the key when associations are exposed as an associated object id."` — the second firing even with no associations exposed at all | Two defaults `CREATE ODATA SERVICE` never set. (1) `Name` (the document) and `ServiceName` (the name in the OData metadata document) are different properties and only the first was set; the CONSUMED path had defaulted this for CE0339 all along. (2) `PublishAssociations` defaults to false = "associations as an associated object id", which Mendix only allows when the system `ID` is published as the key — but MDL's `expose (Attr (KEY))` publishes an ordinary attribute | `mdl/executor/cmd_odata.go` (`serviceName` fallback + heal on create-or-modify; `publishAssociationsFor`; `nonPersistablePublishedEntities` warning), `mdl/ast/ast_odata.go` + `mdl/visitor/visitor_odata.go` (`PublishAssociationsSet`) | **Wider than reported**: the finding framed CE7375 as a non-persistable-entity problem. Measured on 11.12.1, the identical service with a PERSISTENT entity and a unique key builds 0 errors with `true` and CE7375 with `false` — so the default broke *every* published service, and non-persistable was just where it could not be worked around. Defaulting to true does not pick a preference; it picks the only value that can build from the MDL people write. Needs tri-state (`PublishAssociationsSet`) so an explicit `false` is still honoured, and `create or modify` no longer flips a stored value the author did not mention. **Why nothing caught it**: `mdl-examples/doctype-tests/10-odata-examples.mdl` sets both properties explicitly, so the repo's own example worked around both defaults. Tests `cmd_odata_service_name_test.go`, `cmd_odata_publish_associations_test.go`; examples `f1-10.1-…`, `f1-10.4-…`. mxcli-formula1 #10.1/#10.4 | -| A typo in an OData property — `ReadMicroflow:` for `ReadMode:`, `ServiceNam:` for `ServiceName:` — passes `mxcli check` and `exec` reports success, but the model does not have the property. Hours can go into wondering why a published resource ignores its read microflow | The grammar accepts any `name: value` pair inside an OData property list, and the visitor's `switch` had no `default` — so an unrecognised name was dropped between parse and AST. The ALTER path has always answered `"unknown OData service property: %s"`; CREATE, PUBLISH ENTITY, the client and the external entity had nothing | `mdl/ast/ast_odata.go` (`UnknownProperties` on four statements), `mdl/visitor/visitor_odata.go` (four `default:` arms), `mdl/executor/validate_odata_properties.go` (`ValidateODataProperties`, MDL-ODATA01), wired in `cmd/mxcli/cmd_check.go` | The visitor is where the name is lost, so the visitor is where it must be recorded — a validator over the AST alone cannot see a key that was already discarded. Carry it as `UnknownProperties` and report at check time, before anything is written. The message names the property AND guesses the intended one (prefix/substring, then one edit), because a bare known-property list still leaves the reader diffing two spellings by eye. **Correction to the report**: `Pagesize:` is *not* silently dropped — the visitor lowercases before matching, so casing is never a typo, and the test pins that. Verified against every `mdl-examples/**/*.mdl` for false positives (0). Tests `mdl/executor/validate_odata_properties_test.go`. mxcli-formula1 suggested issue 8 | -| A read-microflow-backed OData resource must declare a `System.ODataResponse` parameter and compute a count, even when the count is expensive (a full CSV scan) and nobody asked for it — with no MDL to say otherwise. Same for `$skip`/`$top` support | `Countable`, `SkipSupported` and `TopSupported` were written as literal `true` in the BSON writer's `ODataPublish$QueryOptions`; nothing above the writer could express them | `mdl/ast/ast_odata.go` + `mdl/visitor/visitor_odata.go` (`*bool` on `PublishedEntityDef`, `odataBoolPtr`), `model/types.go`, `mdl/executor/cmd_odata.go` (`astEntityDefToModel`), `mdl/backend/modelsdk/odata_write.go` (`boolOrDefault`), `odata_read_detail.go` (`falseOnly`) | Tri-state (`*bool`) is load-bearing: these default to **true**, so "unset" and "false" cannot share a representation or every existing script would silently turn them off. The reader maps a stored `true` back to nil (`falseOnly`) so DESCRIBE prints only what the author wrote instead of three defaults on every resource. Verified end to end on 11.12.1: `Countable: No` + a read microflow with **no** `$Response` parameter builds 0 errors, which is exactly the combination that was impossible before. Tests `cmd_odata_query_options_test.go`, `odata_write_test.go`. mxcli-formula1 #10.3 | -| `describe odata service Module.Api` emits MDL that will not parse — `ReadMode: CallMicroflow:Module.Read` matches no value, `expose (Module.Entity.Attr ...)` where the clause takes a bare member name — and quietly renames the entity set, printing the entity TYPE's exposed name in the `as '…'` position where the entity SET's belongs | Three independent slips in one emit block. The backend stores a microflow-backed mode as `CallMicroflow:` and a member fully qualified; DESCRIBE printed the stored forms verbatim. The set/type name confusion is invisible in a single-word case and only shows when the two differ (Studio Pro's convention is singular type, plural set) | `mdl/executor/cmd_odata.go` (`odataModeToMDL`, `bareMemberName`, entity-set exposed name, `KEY` over `IsPartOfKey`) | Storage form ≠ input form: anywhere DESCRIBE prints a value read back from the backend, ask whether the *parser* accepts that spelling — `mx check` and the linter never see DESCRIBE output, so nothing else can catch it. Proved by round trip rather than by eye: describe → check (parses) → drop → exec the output → describe again → **byte-identical**, and the rebuilt model reports 0 errors from mxbuild. Tests `cmd_odata_describe_roundtrip_test.go`. mxcli-formula1 #10.5 | -| A `create database connection … type 'Redshift'` (or `'SQLServer'`) executes, builds **0 errors**, and the connection does not work. The skill's own table listed both, and omitted the one value that matters for an unsupported driver | mxcli passes the type string straight to BSON (`addStr(e,"DatabaseType",…)`) and **mxbuild does not validate it either** — verified on 11.12.1 — so nothing between the author and the runtime says the type is not real. Studio Pro's picker (read from `modeler/ide-client/database-connector-editor/`, identical on 11.10.0/11.12.1/11.13.0) is MSSQL, MySQL, Oracle, PostgreSQL, Snowflake, **BYOD** ("Other") — no Redshift, no SQLServer | `mdl/executor/validate_database_type.go` (`ValidateDatabaseConnectionType`, MDL-DB01), wired in `cmd/mxcli/cmd_check.go`; `.claude/skills/mendix/database-connections.md` | **A warning, not an error**: the value set is version-specific and mxcli cannot prove a string wrong on a Mendix version it has not seen — but silence is worse when the build is green and the connection is dead. **`BYOD` is the discovery worth keeping**: it forces connection-string config and *skips the driver-presence check*, so any JDBC driver Mendix has no entry for (DuckDB, SQLite, ClickHouse) works by dropping the JAR in `userlib/`. **Generalisable**: when a doc lists enum values, the shipped Studio Pro editor bundle is the authority — grep `id:"…",label:"…"` out of `ide-client/`, and diff across cached versions to see whether the set moved. Tests `validate_database_type_test.go`. mxcli-formula1 #6 | -| `mxcli init` run from a solution root (several app folders, no `.mpr` at the root) reports success and writes tooling that points at `project.mpr` — a file that does not exist. Nobody is told which project it picked, because it did not pick one | `findMprFile` looks only in the target directory; an empty result fell through to a hardcoded `"project.mpr"` default and initialisation continued as if that were a real project | `cmd/mxcli/init.go` (`findMprFilesInSubdirs` + the three-way branch on the candidate count) | Look one level down, then branch on the **count**: 0 → warn that generated paths will be placeholders; 1 → announce the project and initialise **that** directory; 2+ → refuse, list them, and print the exact command naming one. One level only — a Mendix app keeps its `.mpr` at its own root, and walking deeper starts finding deployment copies and backups. Candidates are sorted so the refusal and its suggested command are stable rather than directory-order dependent. **Generalisable**: a "sensible default" that names a file which does not exist is not a default, it is a silent wrong answer — count the candidates and let the count choose the behaviour. Tests `cmd/mxcli/init_discover_test.go`. mxcli-formula1 #3 | -| `alter settings configuration 'Default' …` is the write form, but `describe settings configuration 'Default'` is a **parse error** — and `show settings configurations` summarises the configuration without `ApplicationRootUrl`, so the obvious command for "did my root URL land?" cannot answer it (and renders an empty DatabaseUrl as a bare `, ,`) | The grammar's `DESCRIBE SETTINGS` alternative took no object, so the read form of a write statement simply did not exist; the summary builder listed database/port fields and never gained the root URL when that property was added | `mdl/grammar/domains/MDLCatalog.g4` (`DESCRIBE SETTINGS (CONFIGURATION STRING_LITERAL)?`), `mdl/visitor/visitor_query.go` (reuses `DescribeStmt.Qualifier`), `mdl/executor/cmd_settings.go` (`writeSettingsConfiguration`, `describeSettingsConfiguration`, summary) | **Read forms should mirror write forms** — where MDL has `alter X `, `describe X ` should parse, and reaching for it and getting a parse error teaches the wrong lesson. Factor the emit into one helper so the whole-settings dump and the single-configuration dump cannot drift. An unknown name lists the ones that exist, or the user is guessing. **Watch for**: changing `describeSettings`'s signature broke three existing callers, and the same commit's `IsPartOfKey`→`KEY` change broke an OData round-trip test that only the FULL suite caught — run `go test ./mdl/...`, not just the new test. Tests `cmd_settings_configuration_test.go`. mxcli-formula1 #8 | -| `ALTER MODULE X ADD JAR DEPENDENCY (…)` succeeds, `list jar dependencies` reports it, the build is **green** — and the runtime throws `SQLException: No JDBC driver found in app for URL`. `deployment/build.gradle` has no dependencies block and `find deployment -iname '**'` returns nothing | Not a bad write. Declaring and resolving are **separate steps**: the model records the coordinate, and `mx sync-java-dependencies ` is what downloads it into `vendorlib/`. Studio Pro runs that when you edit Module Settings; nothing headless was running it. Confirmed on 11.12.1 — a full `mxbuild --target=deploy` resolves nothing, and the sync command then fetches the jar | `cmd/mxcli/docker/javadeps.go` (`SyncJavaDependencies`, `UnvendoredJarDependencies`), `cmd/mxcli/cmd_sync_java_deps.go` (`mxcli sync-java-deps [--check]`), `cmd/mxcli/docker/runlocal.go` (vendors before boot), `mdl/executor/cmd_modules.go` (`warnUnvendoredJarDependencies`) | **How to find the missing step**: the reporter's open question was "does mxbuild skip Maven resolution, or does mxcli write it somewhere MxBuild cannot read?" — neither. `strings mx.dll | grep -i dependenc` surfaced `ISyncJavaDependenciesRunner`/`SkipManagedDependencySync`, and `mx --help` listed `sync-java-dependencies`. When a model-level write "works" but the artefact never appears, check whether the **toolset** has a separate command for it before suspecting the write. Wired at three levels so the gap cannot stay silent: the executor says so the moment it writes an unvendored coordinate, `run --local` resolves it before boot, and `--check` exits non-zero as a build gate. Resolution needs network, so every call site is best-effort with an actionable message. Tests `cmd/mxcli/docker/javadeps_test.go`. mxcli-formula1 #12 | -| `$Total = 5;` does not parse — `no viable alternative at input '$Total=5'` — while `DECLARE $Total Integer = 0;` does, and so do `$X = HEAD($List)`, `$X = create M.E (…)` and `$X = execute database query …`. The error names the token, not the missing keyword | Assignment existed only as a **prefix** on specific activity statements (`(VARIABLE EQUALS)?` on CALL/CREATE/RETRIEVE/…), plus a `SET $Var = expression` statement. A plain value therefore required `SET`, which nothing in the error or the surrounding syntax suggested | `mdl/grammar/domains/MDLMicroflow.g4` (`setStatement : SET? …`), `cmd/mxcli/syntax/features_microflow.go` | Make the guessable form work rather than improve the error: `SET` is now optional and both spellings produce the same `MfSetStmt`/`ChangeVariableAction`. **Prove a grammar relaxation causes no regressions with a control binary, not by reading**: `git stash` the `.g4`, `make grammar`, build `bin/mxcli-control`, sweep every `mdl-examples/**/*.mdl` with both — 13 scripts fail, the *same* 13, all pre-existing. ANTLR's adaptive prediction picks the activity-prefixed alternatives over `setStatement` on its own; no ordering change was needed. Executed against a real .mpr, mxbuild reports 0 errors. Tests `mdl/visitor/visitor_microflow_bare_assign_test.go` (bare and keyword forms must agree on the AST, not merely both parse). mxcli-formula1 #13 | -| `mxcli test tests/ -p app/App.mpr` fails with "no such file or directory" for a `tests/` that sits right next to the `.mpr` | Test paths resolved against the process CWD only. Defensible in isolation, but mxcli otherwise encourages naming the project (`-p`) rather than standing in its directory, and project auto-discovery searches outward — so the two conventions collide and the failure looks like a missing directory | `cmd/mxcli/cmd_test_run.go` (`resolveTestPaths`) | Fall back to project-relative **only when the CWD-relative path does not exist**: a `tests/` in both places must resolve to the one the user is standing in, since silently preferring the project's copy would run the wrong suite. A path that exists in neither is passed through unchanged so the error names what was typed, not a rewritten path the user never mentioned. Tests `cmd/mxcli/cmd_test_run_paths_test.go`. mxcli-formula1 #13 | -| A test annotated `@cleanup rollback` (or with no `@cleanup` at all — rollback is the documented default) still leaves its rows in the database; a misspelled strategy like `@cleanup rollbak` does the same, silently, while the run reports PASS | `TestCase.Cleanup` was parsed and then used nowhere. The after-startup runner had no seam to implement it — tests run inside the startup action, so there is no context the runner owns. The test endpoint creates that seam: it builds the `IContext` each test runs on | `cmd/mxcli/testrunner/endpoint.go` (the handler's execute block), `cmd/mxcli/testrunner/cleanup_strategy.go` | Wrap the call in `ctx.startTransaction()` … `ctx.rollbackTransaction()` in a **finally** (a throwing test is the one most likely to leave half-written data), gated on a `rollback=1` query parameter the client sends per test. Report `rolledBack`/`rollbackError` in the response and warn per test — a rollback that fails silently is worse than none. Reject an unknown `@cleanup` value at **parse** time so `--list` catches it too. Verify against the database, not the endpoint's own claim: run one test with rollback and one with `@cleanup none` in the same suite and query Postgres — the `none` row must be the only survivor | -| A suite passes under `mxcli test --attach` and fails under `--local`, with assertions that depend on startup state (a loaded cache, seeded reference data) seeing zero rows | The `--local` runner pointed after-startup at its own registration microflow and did **not** chain the project's own, so the app's startup logic never ran. It was a deliberate choice (a known baseline) but was invisible: the run printed only `After-startup set to MxTest.RegisterEndpoint`, never that the user's microflow had been displaced | `cmd/mxcli/testrunner/runner.go` (`runEndpoint`), `cmd/mxcli/testrunner/cleanup_strategy.go` (`describeStartup`) | Capture project state **before** generating the endpoint MDL, and pass `state.afterStartup` to `GenerateEndpointMDL` so the generated flow chains it — the hosted `--test-endpoint` path already did this, and the mismatch between the two was the bug. Add `--skip-app-startup` for a deterministic empty baseline, and always print which of the two happened. Note the startup microflow's writes run at boot, outside any test transaction, so `@cleanup rollback` does not undo them. mxcli-formula1 findings #19 | -| `mxcli test tests/ -p app/App.mpr --list` fails with `stat tests/: no such file or directory` while the same command without `--list` runs fine | The `--list` branch passed raw `args` to `ListTests`, bypassing `resolveTestPaths` — so a path relative to the project (rather than the working directory) resolved for execution but not for listing | `cmd/mxcli/cmd_test_run.go` (the `if list` branch) | Pass `resolveTestPaths(args, projectPath)` there too. When a command has two entry points into the same input, check both go through the same path resolution. mxcli-formula1 findings #15 | -| After `SET` became optional, `mx check` on a project built from `02-microflow-examples.mdl` reports six errors that no MDL change caused: `[CE0109] "Undefined variable 'ProductList.Price'."` at four Aggregate list activities, and `[CE0015] "Aggregate function must specify a valid attribute."` at the expression-based one. Identical on both engines. `mxcli check` on the same script is silent | Two conversions existed for one syntax. `$Sum = sum($List.Price)` used to reach the dedicated `aggregateListStatement` rule; making `SET` optional put `setStatement` — alternative 5 of ~50 — in front of it, so ANTLR matched the lower-numbered alternative and the statement fell through to `buildSetStatement`'s fallback conversion, which joined list and attribute into one name and dropped the per-item expression entirely. Underneath, `buildListAggregateAsFunction` never appended the expression argument, so the SET path could not have seen it either | `mdl/grammar/domains/MDLMicroflow.g4` (`setStatement` moved LAST in `microflowStatement`), `mdl/visitor/visitor_microflow_statements.go` (`buildSetAggregate` replaces `extractVariableAndAttribute`), `mdl/visitor/visitor_microflow_expression.go` (`buildListAggregateAsFunction` appends the expression argument) | **A permissive alternative belongs last.** `$X = ` overlaps every `VARIABLE EQUALS ` statement in the rule — aggregates, list operations, RANGE — and ANTLR's ALL(*) picks the lowest-numbered alternative that matches, so a new general form silently steals from every specific one above it. **The measurement trap that let this ship**: the `SET?` change was swept with a control binary over every `mdl-examples/**/*.mdl` and found no difference — but with `mxcli check`, which parses and validates and never serializes. This defect lives between the AST and the BSON, where only `exec` + `mx check` can see it. Sweeping with `check` proves the grammar still *parses*; it proves nothing about what the visitor *builds*. For a grammar change, the control sweep must run the integration gate (`go test -tags integration -run TestMxCheck_DoctypeScripts`), not `check`. Fix proven by reverting both halves and watching the new tests fail with the reported symptom. Tests `mdl/visitor/visitor_microflow_aggregate_test.go`. Upstream CI on the ako→mendixlabs sync PR | -| `DESCRIBE microflow` prints a bare `else` on a `split type` the author never wrote one for, and each describe→exec pass accumulates another | An object-type decision always carries an `(empty)` outgoing flow (the null-object case), emitted by the builder whether or not an `else` was written. DESCRIBE rendered that flow as `else`. Invisible until the `InheritanceCase` writer landed — before that every branch degraded to `NoCase`, so nothing distinguished it from a real case | `mdl/executor/cmd_microflows_show_helpers.go` (the `elseFlow` block in the inheritance-split traversal) | Drop the `else` line when its body renders empty — the same `elseLineIdx`/truncate pattern the if/else emitters already use. Exec re-creates the flow, so the omission is lossless and the roundtrip is stable. **Do NOT 'fix' this in the builder**: removing the empty-entity branch there fails the build with **CE0089** "The '(empty)' value should be configured for an outgoing flow" — that flow is load-bearing and is why `else` cannot substitute for the base entity's case (CE0090); `(empty)` and the base type cover different things. That wrong fix was implemented first and caught only because every shape was re-run through mxbuild, not because a unit test failed. Tests `TestBuilder_InheritanceSplitKeepsEmptyCaseFlow` (builder must KEEP it) and `TestTraverseFlow_InheritanceSplitOmitsEmptyElse` (describe must not print it) | -| `alter page … set on ` reports `widget "X" not found` when the widget sits inside a datagrid column rendered as `customContent`; only CREATE OR REPLACE PAGE can touch it | `findInWidgetChildren`'s pluggable branch searched the grid's own `Object.Properties[].Value.Widgets` and matched columns by derived name, but never descended into a COLUMN's own content. Columns live at `Object.Properties[columns].Value.Objects[]`; their widgets are one level deeper at `Properties[content].Value.Widgets[]` | `mdl/backend/pagemutator/mutator.go` (`findInWidgetChildren`, the columns loop) | Descend into each column's `Properties[].Value` and reuse `findInWidgetArray(…, "Widgets", name)`. **Address by the nested widget's OWN name, not a `grid.column.widget` path**: DataGrid2 columns carry no stored name in the MPR (see `findBsonColumn`), so a column segment could only be a derived name that changes when the caption is edited — a path that goes silently stale. Keep the existing column-by-derived-name lookup working (a test pins it, or the descent could shadow it). Test `TestFindBsonWidget_InsideCustomContentColumn`; repro `mdl-examples/bug-tests/834-alter-customcontent-column-widget.mdl`. Issue #834 | -| `alter page … set Caption = '…' on ` fails with `widget has no Caption property` — for EVERY action button, nested or top-level | An ActionButton has no `Caption` document: its caption is a `Forms$ClientTemplate` under **`CaptionTemplate`** (Template → Items[] → Translation.Text), the same shape `setWidgetContentMut` already walked for `Content`. `setWidgetCaptionMut` only looked for `Caption` | `mdl/backend/pagemutator/mutator.go` (`setWidgetCaptionMut`, `setClientTemplateText`) | Fall back to `CaptionTemplate` via a shared `setClientTemplateText` helper, which `setWidgetContentMut` now uses too. **Found while fixing #834 — check whether a symptom reproduces OUTSIDE the reported context before attributing it**: this failed on a top-level button as well, so it was a second, independent defect and the #834 finder fix alone would not have made the reporter's command work. When a widget property will not set, dump the widget's key list (`mxcli bson dump --type page`) before assuming the setter is wired — the field is often stored under a different, template-shaped key | -| A published service will not build: every whole-number attribute is `[CE5016] "Attribute … has type Integer, but is published as Edm.Int32"`, and an exposed enumeration adds CE5016 plus `[CE4583] "Enumeration 'X' is not published in this service."` | `mendixAttrTypeToEdm` mapped Integer→Int32 (Mendix publishes it as **Int64**, same as Long), and the enum path wrote `Edm.String` while `EnumerationAsString` was hardcoded `false` — the one combination Mendix rejects, since with the flag false it wants the enumeration published as its own EDM enum type. The function's own comment flagged the unverified rows, and the existing unit test *pinned the wrong answer* | `mdl/executor/cmd_odata.go` (`mendixAttrTypeToEdm`, `enumPublishedAsString`, `publishedAttrType`), `model/types.go` (`PublishedMember.EnumerationAsString`), `mdl/backend/modelsdk/odata_write.go` + `sdk/mpr/writer_odata.go` (stop hardcoding the flag) | **Let mxbuild adjudicate the whole table at once**: publish one attribute of every Mendix type in one service and read the CE5016s off the build. That found Integer (reported) *and* Enumeration (only suspected), and confirmed String/Long/Decimal/Boolean/DateTime were already right — five verified rows for one build. Binary turns out to be unpublishable at all (CE5013), whatever type you give it. **A type and a flag that only work as a pair must travel as a pair** — `Edm.String` is ambiguous between String and a flattened enum, so the flag is the only thing distinguishing them and it belongs on the same struct. Watch for an existing test that encodes the bug: this one asserted `Edm.Int32`, so the fix *failed the suite* until the assertion was corrected. Tests `cmd_contract_test.go`, `cmd_odata_edm_type_test.go`. mxcli-formula1 #16 | -| `create or modify external entity Mod.E (… Countable: false)` — touching only an entity-level property — detonates every attribute: `[CE6612] "Attribute 'circuitId' of external entity 'Stg_Circuit' is not supported."`, one per attribute, leaving a project that cannot build | Not the executor: it already preserves attributes it was not asked to change (`if len(attrs) > 0`). One layer down, `attributeFromGen` handled `StoredValue` and `OqlViewValue` but **not** `Rest$ODataMappedValue`, so every attribute of an external entity read back with no `RemoteName`, and the writer's `isExternal && a.RemoteName != ""` arm then fell through to a plain StoredValue on the next read-modify-write | `mdl/backend/modelsdk/domainmodel.go` (`attributeFromGen` gains the `ODataMappedValue` / `ODataMappedPrimitiveCollectionValue` arms) | **The attribute-level half of #782**, which fixed the entity level and stopped there — when a read-modify-write loses data, check every *nesting level* of the read, not just the one named in the report. A polymorphic `Value` switch that silently ignores a variant is the shape to look for: it compiles, it reads, and it drops. Reproduce with a **local metadata file** (`MetadataUrl: './contract.xml'`) — no server needed, and the import is the same code path. Also learned here: the reported attribute *rename* (`name` → `Stg_Circuitname`) is a different thing entirely — it happens at import, from `reservedEntityAttrNames`, and `name` is **not** actually reserved (verified: Mendix builds an external entity with an attribute literally named `name`). Tests `external_entity_read_test.go`. mxcli-formula1 #25 | -| `execute database query … dynamic $Sql` reaches the runtime as the literal string `'$Sql'` — `Parser Error: syntax error at or near "$"` from the database, not from Mendix. Runtime-built SQL, and therefore query pushdown, is impossible | The builder quoted any dynamic query not already starting with a quote — right for `dynamic 'SELECT …'`, wrong for an expression — and the AST kept one `DynamicQuery` string whichever branch of the grammar produced it, so nothing downstream could tell them apart | `mdl/ast/ast_microflow.go` (`DynamicQueryIsExpression`), `mdl/visitor/visitor_microflow_actions.go` (set it in the `expr` branch), `mdl/executor/cmd_microflows_builder_calls.go` (`dynamicQueryExpression`) | **When a grammar has two alternatives that mean different things, the AST must record which one fired** — a shared field plus a "does it look quoted?" heuristic is a guess, and the workaround users find (`dynamic '' + $Sql`, which starts with a quote so the heuristic leaves it alone) is proof the heuristic is the bug. Verified by reading the stored BSON rather than by describe: `DynamicQuery\x00\x05\x00\x00\x00$Sql` — five bytes, no quotes. Tests `cmd_microflows_dynamic_query_test.go`. mxcli-formula1 #21 | -| `create odata client` against a service behind `authentication basic` prints `Warning: could not fetch $metadata: … HTTP 401`, creates the client anyway, and the following `create external entities from …` imports nothing — from a script that reports success | The statement's `HttpUsername`/`HttpPassword`/`HEADERS` are stored for the runtime, but the design-time fetch was a bare `client.Get`. The fetch failure is only a warning, so the empty client propagates silently | `mdl/executor/cmd_odata.go` (`metadataFetchAuth`, `metadataAuthFromStmt`, `fetchODataMetadata`), `mdl/ast/ast_odata.go` + `mdl/visitor/visitor_odata.go` (`HttpUsernameIsLiteral` / `HeaderDef.ValueIsLiteral`) | **Only a literal is usable at design time.** The visitor strips a quoted literal's quotes, so `'f1api'` and `Module.ApiUser` both arrive as bare strings — the AST has to record which was written, or mxcli sends a *constant's name* as the password. Unresolved names are reported instead, which is also the honest answer: mxcli has no runtime to resolve a constant against. **A warning on a step something else silently depends on needs to say what breaks next** — the message now names the empty client and the import that will do nothing. Verified against a real basic-auth server that 401s without credentials and 403s without the custom header, so both had to arrive. Tests `cmd_odata_metadata_auth_test.go`. mxcli-formula1 #23 | -| Re-running `create or modify odata service` after editing a `publish entity` block changes nothing — the served `$metadata` is identical, and only `drop odata service` + create picks the edit up | The modify branch updated the service's scalar properties and never touched `EntityTypes` / `EntitySets` | `mdl/executor/cmd_odata.go` (modify branch rebuilds published entities via `astEntityDefToModel`, and carries `AllowedModuleRoles` through) | **Replace, don't merge**: a member removed from the script has to leave the service, which merging cannot express — the script is the description of the service. **Carry through what the statement cannot express**: role grants come from a separate `grant access on odata service` and would otherwise be dropped by a modify (reported; *not* reproduced on 11.12.1 — kept as a guard, and the commit says so rather than claiming a fix). Verified: same script yields `Label as 'label'` before and `Label as 'label' (Filterable, Sortable)` after, build stays at 0 errors. Tests `cmd_odata_modify_members_test.go`. mxcli-formula1 #26 | -| `create external entities from` a contract that restricts capabilities produces a project that will not build: `'Seasons' is marked Countable=False in the OData service, but True in the app`, `'latitude' is marked Filterable=False …` — one per restricted resource or property | Insert/Update/Delete restrictions were parsed; **Count/Filter/Sort were not**, so the import had nothing to honour and defaulted all three to true — on the one command whose entire job is fidelity to the contract | `mdl/types/edmx.go` (`EdmEntitySet.Countable`, `NonFilterableProperties`, `NonSortableProperties` + the three `applyCapabilityAnnotations` arms), `mdl/executor/cmd_contract.go` | An unannotated set still means countable/filterable/sortable — **silence in a contract is not a restriction**, it is OData's own default, so `nil` and `false` must stay distinguishable (`*bool`, as with the publish-side query options). The generated entity is compared against the contract at *build* time, so anything the contract can say is something the importer must be able to read. Tests `mdl/types/edmx_test.go`. mxcli-formula1 #24 | -| A contract property called `name` is generated as `Stg_Drivername` / `Circuitname` — prefixed with the remote type. A page written against the published `$metadata` then fails with `The selected attribute 'F1Live.Drivers.name' no longer exists`, and the *same* field carries a different name in every module because the remote type names differ | `attrNameForOData` disambiguates any name in `reservedEntityAttrNames`, and `name` was on that list with the comment "Mendix system-managed attribute for the object name". It is not: Mendix builds an external entity with an attribute literally named `name` | `mdl/executor/cmd_contract.go` (`reservedEntityAttrNames` loses one entry; the import now reports the renames it does make) | **Test the whole list at once, not the reported entry.** One contract with a property per listed name, prefixing disabled, then `mx check`: CE7247 "The name 'x' is a reserved word" for `id`/`owner`/`changedBy`/`changedDate`/`createdDate`/`type`/`context`, and silence for `name`. That turns "is the list wrong?" into "which rows are wrong?" for the cost of a single build, and it *earns* the seven entries that stay rather than leaving them as folklore. Two existing tests pinned the old behaviour and had to be corrected — a hand-maintained list of platform rules will accrete guesses unless each row can point at an error code. **Migration**: a re-import renames the attribute back, so references to the prefixed name must follow. Tests `cmd_contract_reserved_test.go`. mxcli-formula1 #28 | -| `MOVE JAVA ACTION …` / `MOVE ODATA SERVICE …` is a parse error (`no viable alternative at input 'MOVEJAVA'`), and neither `CREATE` form takes a folder clause — so those documents can never leave the module root from MDL | The `moveStatement` rule listed seven doctypes and nothing else; the missing ones were never unimplemented, just unlisted | `mdl/grammar/MDLParser.g4` (two alternatives), `mdl/ast/ast.go`, `mdl/visitor/visitor_entity.go` (dispatch **and** the MOVE FOLDER discriminator), `mdl/executor/cmd_move.go`, backend `MoveJavaAction` / `MovePublishedODataService`, `sdk/mpr` exports `MoveUnitByID` | Both reduce to the existing reparent primitive — a top-level document move is one containment row, so a new doctype is a list entry plus a lookup, not new machinery. **Watch the discriminator**: `MOVE FOLDER` is told apart from a document move by the *absence* of a doctype keyword, so every keyword added to the rule must also be added to that condition or a folder move starts parsing as a document move. **Verify placement by differential count, not by reading the model**: run the script with and without the MOVE lines and diff `select ContainmentName, count(*) from Unit` — three new Folders rows (a nested path creates two) and an unchanged Documents count says reparented rather than copied or dropped. Grepping blobs for names is a trap; stock modules are full of the same words. Tests `visitor_move_doctypes_test.go`, example in `18-folder-examples.mdl` — which must sit **before** that script's `drop module`, a mistake the integration gate caught and `mxcli check` did not. mxcli-formula1 #32 | -| `create odata client` with credentials given as constants (`HttpUsername: '@Module.ApiUser'`) still gets HTTP 401 and an empty client, after the fix that made literal credentials work. Sharpened by the same release making a constant `ServiceUrl` mandatory — the shape the tool insists on is the shape whose credentials it will not read | `resolveCredential` trusted the visitor's isLiteral flag. `'@Module.ApiUser'` **is** a STRING_LITERAL, so the flag said "literal" and the previous code sent the fifteen characters `@Module.ApiUser` as the username — and the unresolved-credential note did not fire either, because as far as the code knew nothing was unresolved | `mdl/executor/cmd_odata.go` (`resolveCredential`, `constantReference`, `designTimeConstants`) | **A syntactic classification is not a semantic one.** The visitor can say "this was a quoted string"; only the executor can say "this quoted string names a constant". Any flag of the form isLiteral needs the consumer to ask what the literal *contains* before treating it as a value. **The fix is to resolve, not to refuse**: a constant's design-time default is exactly what Studio Pro sends on the same fetch, so reading it is the value rather than a workaround — and mxcli already has the project open. Three spellings must all work (`'v'`, `@M.C`, `'@M.C'`); a dotted literal like a password containing a dot must not be mistaken for a reference. Tests `cmd_odata_metadata_auth_test.go`. mxcli-formula1 #23 follow-up | -| An app themed dark still shows light-mode drop shadows under the datagrid's filter-operator popover and dropdown filter lists | The generated widget layer re-pointed `.column-selectors` but not the four rules in `_datagrid-filters.scss` that bake the same two-layer shadow. Each already takes its *background* from `--bg-color-secondary`, so Atlas re-colours the panel and leaves the shadow — which is why it reads as a partial fix rather than an untouched widget | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-widgets.scss` | **Read the selectors out of the shipped `themesource/`, not the bug report** — the fourth here is `.dropdown-container .dropdown-list`, nested under a parent the report did not name. **Verify in the compiled CSS, never the source** (the §33 rule): apply the theme to a real project, run `mxbuild --target=deploy --java-home=… --java-exe-path=…`, then grep `theme-cache/web/theme.compiled.css` and check the *line number* — the fix must land after the widget module's own rule (30794 vs 27765 here) or the cascade eats it. A theme-cache file older than the SCSS you just wrote is a stale compile, and mtime is the cheapest way to catch it. mxcli-formula1 #33 / suggested issue 4 | -| A `MOVE` cannot be confirmed and a module's layout cannot be reviewed: `SHOW STRUCTURE` groups by document type at every depth and never names a folder, `DESCRIBE` answers for one document at a time, so checking where things ended up means opening the `.mpr` as SQLite | The read side of folders was simply never built. `MOVE`/`DROP FOLDER` write containment; nothing read it back | New `mdl/executor/cmd_list_folders.go` + grammar (`FOLDERS` lexer token, `showOrList FOLDERS (IN …)?` in `MDLCatalog.g4`, `FOLDERS` added to the `keyword` rule so it stays usable as an identifier), `mdl/ast/ast_query.go`, `mdl/visitor/visitor_query.go`, `mdl/executor/executor_query.go` | **A layout listing must show what is *not* there**: empty folders (`[0]`) and documents still at the module root, or it cannot be diffed against an intended layout — that is the whole use. Documents are indexed by `ContainerID` across every list call the backend offers, each best-effort, so a backend that cannot answer one kind yields a listing missing that kind rather than no listing. **Do not stub the hierarchy in the test** — `mkHierarchy` populates `moduleNames` but not `folderNames`, so `BuildFolderPath` returns `""` and every folder silently collapses into the module root, which looks exactly like the bug. Build it from the mock's `ListModules`/`ListUnits`/`ListFolders`, as `getHierarchy` does. Tests `cmd_list_folders_test.go`, example in `18-folder-examples.mdl`. mxcli-formula1 issue #2 | -| Every page in a locally-run app is a black screen. `mxcli check` passes, the build reports success, the runtime log is quiet, `curl /` returns 200 with a valid HTML shell, and every OData service answers — only a browser sees it. It also comes and goes between runs of the same command at the same version | `run --local` bundles the browser client at step 5b, then the runtime boot runs Gradle `clean-custom-classes compile package`, whose package pass repopulates `deployment/web` and takes `dist/` with it — deleting the bundle 51 seconds after the same command wrote it. It only bites when Gradle has work to do (a new Java action, a full recompile), which is why the app boots fine for weeks and then stops | `cmd/mxcli/docker/webclient.go` (`WebClientBundled`, `EnsureWebClientBundle`, `ReportLostWebClientBundle`), `cmd/mxcli/docker/runlocal.go` (step 6b, after the boot), `cmd/mxcli/docker/localapp.go` (headless boots warn instead of paying ~30s) | **A pre-condition established before a step that rewrites the same directory is not a post-condition** — verify after, not before. The guard is a `stat` when the bundle survived, which is what makes it affordable at every boot; re-ordering the bundle to after the boot instead would leave the app reachable-but-blank for ~30s on every cold start. **`curl /` returning 200 is not evidence the app renders**: the shell is served by the runtime, the client by a file the shell references. The one-line check is `curl -o /dev/null -w '%{http_code}' /dist/index.js`. **Do not silently pay for a repair on a path that does not need it** — `test --local` destroys the bundle too, but tests are headless, so it prints the loss and the remedy rather than spending 30s on a two-second loop. Tests `webclient_bundle_test.go`; both controls run (guard never fires → the wipe test fails; guard always fires → the survivor test fails). mxcli-formula1 §35 | -| `DESCRIBE PAGE` reports a drill-down button as `linkbutton b (Caption: 'Weekend', Action: show_page Mod.Page)` — the `(Race: $currentObject)` argument is gone. The model is fine (`mx check` is clean, and an unmapped required page parameter is a consistency error, so it could not have built), but the description reads as a diagnosis mid-hunt and costs cycles fixing a button that was correct | mxcli deliberately writes `ParameterMappings` as an empty array for a page action, because Studio Pro infers the row object from the enclosing widget and rejects an explicit `$currentObject` Argument as CE0115 (#296). That decision was right; its other half was missing. `renderClientActionMDL` read only explicit mappings, and the writer's comment *asserted* DESCRIBE recovered the implicit one — it never did | `mdl/executor/cmd_pages_describe_output.go` (`pageActionParameters`, `targetPageParameterNames`), stale comment corrected in `sdk/mpr/writer_widgets_action.go` | **When a writer stores something implicitly, the reader owes it an explicit reconstruction — and a comment claiming the reader already does is worth nothing until a test says so.** Recover from the *target page's* declared parameters, not from the action, since that is where the information actually lives. Guard both ends: an explicit mapping still wins (recovery fills a gap, it does not override Studio Pro), and an unresolvable page yields no arguments rather than invented ones — a description that omits an argument is recoverable, one that names a parameter that does not exist is not. **A lossy DESCRIBE is costliest exactly when it is most used**, because DESCRIBE is what you reach for once you have stopped trusting the model. Tests `cmd_pages_describe_pageparams_test.go`; the control (explicit-only) reproduces the reported output verbatim. mxcli-formula1 §39 | -| `create or modify odata service` silently revokes the service's access; the next build fails with "At least one allowed role must be selected for the published OData service to be accessible." Re-granting fixes it until the next modify | `serializePublishedODataService` never wrote `AllowedModuleRoles`. The document is serialized wholesale and written with `updateUnit`, so a field the serializer omits is not left alone — it is deleted. The grants were read correctly and carried through the executor, then dropped one layer down | `sdk/mpr/writer_odata.go` (`AllowedModuleRoles`, marker 1 / BY_NAME, matching the working `GRANT` path's `makeMendixStringArray`); stale executor comment corrected in `mdl/executor/cmd_odata.go` | **A wholesale re-serialization deletes every field it does not write, so the writer's field list is a data-retention policy.** Audit it against the parser, not against the struct — the round trip is the contract. **Where you look decides what you conclude**: an earlier pass looked for this loss at model level, found the value present and carried, and recorded "reported but does not reproduce" — the loss only exists after the BSON round trip, so a model-level check could never have seen it. When a report says a value disappears, reproduce at the persistence boundary before disbelieving it. For the marker, copy the shape from the code path that already works (`GRANT` writes marker 1 and builds fine) instead of reasoning from an unrelated type. Tests `writer_odata_test.go`; the control (field omitted again) reproduces the empty-grants document. mxcli-formula1 §26 | -| `.ai-context/skills/` still carries the previous release's guidance after the mxcli binary is upgraded — binary rebuilt at 12:05, skills stamped the day before — with no warning that they disagree | The skills are embedded in the binary and written exactly once, by `mxcli init`. Nothing re-ran init on upgrade, and nothing compared what was on disk against what the binary carried | New `cmd/mxcli/init_skills_sync.go` (`syncAIContextSkills`, `reportSkillSync`), `--sync-skills` flag in `cmd/mxcli/init.go`, and a sync step in the SessionStart bootstrap in `cmd/mxcli/init_hook.go` | **Stale guidance is worse than missing guidance**, because an agent reads it with identical confidence either way — so the failure mode is silent and confident. Fix it where it is consumed, not where it is authored: the SessionStart bootstrap already runs on every session and can fetch the binary, so it is the one place guaranteed to execute immediately before an agent reads the files. Two properties keep an every-session job acceptable: **write only what differs** (an mtime that moves every session makes "when did this last change" unanswerable — a test asserts the mtime holds) and **stay silent when current**. Never fatal: a skills refresh must not block a session, hence `|| true`. A test asserts the bootstrap script actually calls it *before* the exec'd setup, since a step ordered after an `exec` never runs. Tests `init_skills_sync_test.go`; the control (write-once) leaves the stale file in place. mxcli-formula1 §16 | -| `publish entity … (TopSupported: No)` (or `SkipSupported`/`Countable`) parses, `DESCRIBE` reads it back as `No`, and the published `$metadata` still advertises `true`. A client then believes paging works when nothing implements it | `serializeEntitySet` hardcoded all three `QueryOptions` to `true`, ignoring the `*bool` fields the model already carried — the AST/model/DESCRIBE half of the feature shipped without the writer half | `sdk/mpr/writer_odata.go` (`boolOrTrue`, entity-set `QueryOptions`) | **A capability annotation on a microflow-backed resource is load-bearing, not decorative.** Mendix applies *no* query options to a read-microflow resource — it hands the request over and returns what comes back — so the annotation is the only thing a client has to go on, and an over-claim is not cosmetic: the client reads a whole collection believing it is a page. **Check the writer whenever a property round-trips correctly through DESCRIBE**: DESCRIBE reads the model, so a model-only feature describes perfectly and publishes wrong, and the round-trip test everyone reaches for cannot see it. A tri-state `*bool` needs an explicit nil policy at the boundary (`nil` = the platform default, only an explicit `false` opts out) or the pointer is pointless. Tests `writer_odata_test.go`; the control (hardcoded true) reproduces the over-claim. mxcli-formula1 §20 | -| A published OData resource backed by a read microflow silently returns the wrong thing: `?$top=5` yields the whole collection with a 200, and a client re-reading a held row by key gets the collection default and adopts the FIRST row as that object's identity. No error anywhere — well-formed request, valid collection, correct `$count`, 200 | Two promises the service makes on the microflow's behalf and nothing checked: the `KEY` in `expose (…)`, and the `TopSupported`/`SkipSupported` annotations (which default to **true** when unspecified). Mendix applies no query options to a read-microflow resource — it hands over the request and returns what comes back | New `mdl/executor/validate_odata_read_contract.go` (MDL-ODATA02 key promise, MDL-ODATA03 capability over-claim), wired in `cmd/mxcli/cmd_check.go`; the response contract documented in `.claude/skills/mendix/odata-data-sharing.md` | **A read microflow cannot answer 400** — unlike an OData action or an insert/update/delete microflow, the read capability has no `System.HttpResponse` parameter ([docs](https://docs.mendix.com/refguide/published-odata-entity/#custom-http-response)). Its contract is therefore *declarative*: declaring `TopSupported: No` is the read path's only substitute for the refusal it cannot send. **Pick a trigger you can prove**: both rules fire only when the microflow takes no `System.HttpRequest` parameter, because then it provably cannot see a key or a query option; a microflow that does take it gets the benefit of the doubt, since proving *which* options it parses needs real analysis and a rule that guesses gets switched off. **Verify a check rule against a real parse, never a hand-built AST** — the visitor stores `ReadMode` as `MICROFLOW Module.Name` **upper-cased**, so a case-sensitive prefix match made the whole rule dead while it still looked right; the casing is now pinned by its own test. Tests `validate_odata_read_contract_test.go`, examples in `10-odata-examples.mdl` (both correct shapes: request-aware, and honestly declared). mxcli-formula1 §37/§20, suggested issues 2 and 3 | -| No way to see what a running app's subsystem is doing: everything logs at `INFO`, the detail is not in the log at all, and raising the whole runtime to `TRACE` is unusable on a busy app | Nothing in mxcli drove the runtime's per-node log levels, though the M2EE admin API has exposed them all along | New `cmd/mxcli/docker/loglevels.go` (`GetLogSettings`, `SetLogLevels`, `NormalizeLogLevel`, `MatchLogNodes`), `cmd/mxcli/cmd_log.go` (`mxcli log list` / `log set`), documented in `.claude/skills/mendix/analyze-runtime.md` | **Probe an undocumented API before designing the command around it.** Every fact here came from a live 11.12.1 runtime: `get_log_settings` requires one of `node`/`subscriber`/`sort`; `sort` accepts only `node` and `subscriber`; `set_log_level` takes `{"nodes":[{name,level}],"force":bool}`. **The HTTP response for an AdminException says only "See logging output for details" — the actual message ("Please specify node, subscriber or sort option in params", "Unknown sort option: name", "Unknown LogNode X. Use the 'force' parameter…") is in the runtime log**, so probe with the runtime log open or you learn nothing. `force` means "allow a node that does not exist yet" and **permanently registers the name**, so a typo becomes a real empty node — hence it is opt-in, and an unknown node is an error by default. **Resist the subsystem-specific command**: this was proposed as `mxcli odata trace`, but `set_log_level` takes a list of nodes, so the primitive is generic and the OData knowledge belongs in docs. **The node list is a property of the APP, not of Mendix** — a node appears only once something registers it. A first pass concluded "there is no log node for a published OData service" from a project that had none; adding one service turns 57 nodes into 58 and `OData Publish` (with a space) appears, logging the full incoming URI at TRACE — exactly the question that motivated the command. Enumerating capabilities against one sample app and generalising is the trap; `log list` against *this* app is the answer. Distinguish "cannot reach the admin API" from "the runtime refused the request" (`ErrAdminUnreachable`) or the wrong hint buries the real one. Tests `cmd_log_test.go`, `loglevels_test.go`; verified end-to-end against a booted runtime (both arg forms, multi-node, typo refused, `--force` accepted, unreachable port). mxcli-formula1 suggested issue 4 | -| A headless browser cannot log in to an app started with `--hub`, so every screenshot silently shows the login page and rendering defects survive every other check | Under `--hub` the runtime boots with the public **https** root URL, so it marks session cookies `Secure` and prefixes them `__Host-`; a browser on a non-trustworthy http origin cannot store them | `cmd/mxcli/docker/screenshot_login.go` — the login browser context declares `X-Forwarded-Proto: http` when the target is http (Mendix 10.24+ lets that header override `ApplicationRootUrl`) | **The reported cause did not reproduce as stated, and saying so is part of the fix.** On 11.12.1 an https root URL does *not* block a headless browser on `127.0.0.1`: loopback is a **trustworthy origin**, so Chromium accepts `Secure`/`__Host-` cookies there — the app rendered clean, no console errors, no failed requests. The mechanism is real only for a **non-loopback** http origin (a container hostname, a LAN address). Fix shipped anyway because the header is *accurate* rather than a workaround (the request genuinely is http), it costs nothing on an already-http root URL, and real users over https are unaffected. Verified at the layer the bug lives in: the captured Playwright storage state goes from `__Host-XASSESSIONID(secure=true)` to `XASSESSIONID(secure=false)`. **`curl` cannot see this class of bug and neither can a loopback browser** — when a report blames cookie flags, check whether the origin is trustworthy before believing the flags are the blocker. mxcli-formula1 §38 / suggested issue 7 | -| A `--` comment written between two operands of a Mendix expression ends up **inside** the expression; the build fails **CE0117** "Error(s) in expression". `mxcli check` passes and `DESCRIBE` round-trips the comment, so nothing before mxbuild objects | `extractOriginalText` reads the raw input stream between two token positions — which is exactly what preserves an expression's spacing, and also drags in every token the lexer sent to a hidden channel. MDL's `--` and `/* */` are `-> skip`, so they never appear in `ctx.GetText()` but always appear in the source slice | `mdl/visitor/visitor_helpers.go` (`stripMDLComments`, `extractExpressionText`), applied at the six microflow-expression sites in `visitor_microflow_statements.go` / `visitor_microflow_actions.go` | **The ANTLR trap: `ctx.GetText()` excludes hidden tokens, a source-interval slice includes them.** Any code reaching for original text to preserve formatting inherits every comment in that span. **Replace a comment with whitespace, never with nothing** — `1 --c\n+ 2` must not become `1+ 2`, and `'a'--c\n'b'` must not weld into one token. **Respect single-quoted strings**: a Mendix string may legitimately contain `--` or `/*`, and stripping those corrupts the value (tested both, plus the `''` escape that keeps a string open). **Left OQL alone on purpose** — `visitor_entity.go` uses the same helper for view-entity queries, where `--` is legitimate SQL comment syntax; stripping it would change a different language's meaning. **The unit test alone would not have caught a wiring mistake**: it still passed with `extractExpressionText` bypassed, and only the mxbuild run (1 error → 0, same script, same project) proved the call sites were converted. Tests `visitor_strip_comments_test.go`, repro `mdl-examples/bug-tests/comment-in-expression.mdl`. mxcli-formula1 §34 / suggested issue 11 | -| A published OData entity with no `KEY` fails the build with **CE6585** "Published entity 'X' must have a key defined." — so any advice of the form "drop the KEY" is impossible to follow | Mendix requires every published entity to have a key. MDL-ODATA02's suggestion offered "…or drop the KEY" as the alternative to answering a key lookup, and a doctype example demonstrated that non-existent option | `mdl/executor/validate_odata_read_contract.go` (suggestion text), `mdl-examples/doctype-tests/10-odata-examples.mdl`, `.claude/skills/mendix/odata-data-sharing.md` | **Query options you may decline; the key you may not.** A microflow-backed resource whose rows a client can hold *must* answer the key lookup — there is no opt-out, which makes MDL-ODATA02's real remedy singular rather than a choice. **Verify the remedy a diagnostic recommends, not just the diagnosis** — the rule correctly identified an unanswerable KEY and then proposed something mxbuild rejects, which is worse than saying nothing. This is the second time in one session that a doctype example was validated with `mxcli check` (parse-only) instead of the integration gate; `mxcli check` cannot see CE-codes at all, so **any change to `mdl-examples/doctype-tests/` needs `go test -tags integration -run TestMxCheck_DoctypeScripts/