diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 0fcfdc388..da4d9c638 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -868,4 +868,5 @@ {"area": "mdl/executor", "date": "2026-10-05", "symptom": "On Mendix 11.15, `TestMxCheck_DoctypeScripts/40-message-definition-examples.mdl` fails with CE0270 \"No root element could be found in the schema\" at MsgTest.IMM_Order. On a converted or Studio Pro 11.15 project, `list message definition collections` finds nothing, and `describe import mapping` prints no source", "cause": "Mendix 11.15 replaced `MessageDefinitions$MessageDefinitionCollection` with one `MessageDefinitions$MessageDefinition2` document per definition. `mx convert` turns the collection into a `Projects$Folder` with the same unit ID and name, and the ExposedEntity tree is byte-identical apart from $IDs. mxcli read and wrote only collections, so on 11.15 there was nothing to resolve a mapping against", "file": "`mdl/backend/modelsdk/messagedefinition_document.go` (read/write, reusing the collection's exposedNodeFromGen/messageNodeToGen), `mdl/executor/cmd_messagedefinition_documents.go` (create/describe/drop/list + version refusals), `mdl/executor/mapping_messagedefinition.go` (`findMessageDefinition(ctx, ref)` returns the stored reference), `cmd_messagedefinitions.go` (alter target resolution, association-drop guard), grammar `createMessageDefinitionStatement` / `DROP|DESCRIBE MESSAGE DEFINITION` / `LIST MESSAGE DEFINITIONS`", "insight": "**`mx convert` is the oracle for a storage change.** Converting a known 11.14 project gives a Studio Pro-authored 11.15 reference for the new shape and for how references move, so no guessing is needed. **Under the revert, 11.15 fails earlier than CE0270.** A two-part name written into the old `MessageDefinition` key makes mxbuild refuse to load the project (`StorageLoadException: ... is not a valid OldMessageDefinitionIdentifier`). The old key is typed as a three-part identifier even though 11.15 no longer reads it. The doctype script uses `-- @version: ..11.14` / `11.15+` sections, and the 11.15 mapping needs a different name from the older section's, because a plain `check` sees both sections and reports MDL-DUPDEF", "refs": ["ako/mxcli#987"]} {"area": "mdl/executor", "date": "2026-10-06", "symptom": "v0.25.0: `describe microflow` 4-8x slower than v0.24.0 on a flow arranged by hand in Studio Pro, and the cost grows faster than the flow (60 if/else blocks: 4.7 s -> 35.6 s); flows laid out by mxcli barely change", "cause": "The canonical describe's layout derivation (#748) re-renders the description every round, and a hand-laid flow takes gatedLayoutRounds+2 = 8 rounds. Each render re-ran the stored flow's split/merge analysis (findSplitMergePoints, labelCrossedMerges) and the body warnings (postDominators via microflowgraph.Analyze) - superlinear, and independent of which annotations the round keeps", "file": "`mdl/executor/cmd_microflows_derived_layout.go` (flowAnalysisMemo, installed by useDerivedFlowLayout, rebuildOnly view for the rounds), `mdl/executor/cmd_microflows_show.go` (findSplitMergePoints / labels / microflowBodyWarnings go through it)", "insight": "**Profile before believing the issue's theory.** The report blamed the round count, but rounds were already capped at 8; the rebuild itself was cheap. The CPU profile showed the cost was the *render* inside each round - the stored flow's graph analysis, redone per round. Memoising it per describe (keyed on the stored collection pointer) and leaving the `-- WARNING` comment lines out of rounds (the rebuild never reads them) gave 14.2 s -> 1.7 s at 60 blocks, with the printed description byte-identical to the unfixed build. A wall-clock assertion would be flaky; the test counts split/merge analyses per describe (18 over 8 rounds before, 2 after), with the round count as its control", "refs": ["mendixlabs/mxcli#1301"]} {"area":"mdl-executor","date":"2026-10-05","symptom":"A view entity selecting a **non-localized** DateTime column (Studio Pro's \"Localize\" unticked — the normal choice for a calendar date) passes `mxcli check --references` and exec, then `mx check` fails CE6770 \"View Entity is out of sync with the OQL Query.\" The view attribute was always written LocalizeDate = true, and MDL has no spelling for the flag, so describe -> exec re-broke it on every run","cause":"execCreateViewEntity built every view attribute with convertDataType, whose DateTime is Mendix's default LocalizeDate = true; nothing looked at the attribute the column reads","file":"`mdl/executor/oql_view_localize_date.go` (viewDateTimeLocalize), `mdl/executor/cmd_entities.go` (execCreateViewEntity); test `mdl/executor/view_entity_localize_date_test.go`; bug-test `mdl-examples/bug-tests/1297-view-entity-non-localized-datetime.mdl`","insight":"**Derive, don't add syntax**: on a view entity the column's localization is a property of the query, so the fix reads it from the source attribute and describe -> exec round-trips (`Unchanged`) with nothing new to spell — same reasoning as the view association (the column is the declaration). **Measure the shapes before choosing the scope**: one project, one view per shape, mxbuild 11.12.5 — pass-through, `s/Attr`, MIN, MAX and CASE over a non-localized source are ALL CE6770 when written localized and 0 errors when patched to false, so a pass-through-only rule (the obvious one, mirroring the string-length rule) would have fixed the report and left MAX(date) broken. Sources that disagree (coalesce of a localized and a non-localized column) are unmeasured and keep the default. **Repro needs a Studio Pro-authored flag**: mxcli writes every persistent DateTime localized, so the bug is invisible from MDL alone — the reporter's pymongo patch flipping Sale.SaleDate is the cheapest stand-in. Control: stubbing the assignment fails 5 of 6 cases with `LocalizeDate = true`; pre-fix binary on the same project gives 5 × CE6770, fixed gives 0","refs":["mendixlabs/mxcli#1297"]} +{"area": "mdl-executor", "date": "2026-10-06", "symptom": "`create constant M.Flag (Type: Boolean, DefaultValue: true)` (or `True`) stores DefaultValue \"true\". Studio Pro stores \"True\"/\"False\"; its constant dialog shows a stored \"true\" as False while the runtime reads it as true, so the developer sees one value and the app runs with another. `mx check` is silent and `describe constant` prints `true` for both, so only the stored unit shows it. Quoted `'True'` was the workaround. `alter settings constant @M.Flag value true` (or 'true') wrote the configuration override the same way", "cause": "createConstant rendered the AST literal with fmt.Sprintf(\"%v\"), so a Go bool became Go's lowercase \"true\"; the quoted string passed through verbatim, which is why only 'True' was right. The settings path stored the visitor's token text verbatim and never looked at the constant's type", "file": "`mdl/executor/cmd_constants.go` (storedConstantDefault, used by the create and create-or-modify branches, and by `alterSettingsConstant` in `mdl/executor/cmd_settings.go` via settingsConstantType); test `mdl/executor/cmd_constant_boolean_default_test.go`; bug-test `mdl-examples/bug-tests/1321-boolean-constant-default-case.mdl`", "insight": "**`%v` on an AST literal is Go's spelling, not Mendix's**: any place that stringifies a parsed value into a stored property must normalise to the platform's form. The case-insensitive reader (formatDefaultValue's EqualFold) hid the writer's defect from describe, so a round-trip test could not catch it; assert on the value handed to the backend. Neither `mx check` nor the runtime distinguishes the two spellings — only Studio Pro's dialog does — so a build cannot verify this; the evidence is the reporter's Studio Pro 11.12.4 measurement plus the stored unit. Control: test written before the fix failed 5 of 6 spellings with `stored as \"true\"`/`\"false\"`, the quoted 'True' control passing; exec on a copy of testdata/pedapp then stores True/True/True/False. **Enumerate every write path for the value, not just the reported one**: the configuration override holds the same typed value and had the same defect; it needs the constant's type looked up (a String constant holding 'true' must stay lowercase). Control: the settings test failed 3 of 5 cases with `stored as \"true\"`/`\"FALSE\"` before the fix, the 'True' and String-constant controls passing; the stored Settings unit on a pedapp copy read 'true' for `value true` and `value 'true'` before, 'True' after, the String override 'true' both times", "refs": ["mendixlabs/mxcli#1321"]} {"area": "mdl/executor", "date": "2026-10-07", "symptom": "A button added to a snippet with `alter snippet … insert` (or replace) calling `call microflow M.F(P = $P)` with a snippet parameter fails mxbuild with CE0115 \"The arguments that are passed to microflow 'M.F' do not match the expected parameters and need to be refreshed\" on that button only; the same button in CREATE SNIPPET or via ALTER SNIPPET SET is clean", "cause": "`buildWidgetsFromAST` and `buildColumnSpecsFromAST` (mdl/executor/cmd_alter_page.go) seeded paramScope and localVariables from the mutator but not `isSnippet`, so `classifyFlowArgValue` returned kind \"parameter\" and the Forms$PageVariable named PageParameter instead of SnippetParameter", "file": "`mdl/executor/cmd_alter_page.go` (isSnippet from mutator.ContainerType() in both builders)", "insight": "**A pageBuilder built outside CREATE needs THREE scope fields, not two** — paramScope, localVariables and isSnippet; #1317 found the SET builder missing all three and this one missing the last. When adding a builder, copy the seeding from convertASTAction rather than from memory. **Unlike the Expression-form defect (#1140/#1317), mxbuild DOES catch the wrong slot** — CE0115 rather than CE1571 — so one script exercising CREATE, INSERT and SET on the same snippet with `mx check` is a complete A/B: only the broken path's button is named. Verified on 11.12.2: 1 error before, 0 after", "refs": ["mendixlabs/mxcli#1317", "mendixlabs/mxcli#1140"], "ce": ["CE0115"]} diff --git a/mdl-examples/bug-tests/1321-boolean-constant-default-case.mdl b/mdl-examples/bug-tests/1321-boolean-constant-default-case.mdl new file mode 100644 index 000000000..1f0c085a6 --- /dev/null +++ b/mdl-examples/bug-tests/1321-boolean-constant-default-case.mdl @@ -0,0 +1,28 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1321: Boolean constant DefaultValue stored in Studio Pro's case +-- ============================================================================ +-- +-- Before: `DefaultValue: true` and `DefaultValue: True` were stored as "true". +-- Studio Pro stores a Boolean default as "True" / "False"; its constant dialog +-- shows a stored "true" as False while the runtime reads it as true, so the +-- value a developer sees and the value the app runs with differed. `mx check` +-- reports nothing either way and `describe constant` prints both identically, +-- so the difference only shows in the stored unit. +-- +-- After: every spelling below is stored as "True" or "False". + +create module BugTest1321; + +create constant BugTest1321.FlagA (Type: Boolean, DefaultValue: true); +create constant BugTest1321.FlagB (Type: Boolean, DefaultValue: True); +create constant BugTest1321.FlagC (Type: Boolean, DefaultValue: 'True'); +create constant BugTest1321.FlagD (Type: Boolean, DefaultValue: false); + +-- A configuration override of a Boolean constant had the same defect: the +-- value was stored as typed ("true"). It is now stored as "True" / "False"; +-- a String constant's override is stored as written. +create constant BugTest1321.Label (Type: String, DefaultValue: 'x'); +alter settings constant @BugTest1321.FlagD value true; +alter settings constant @BugTest1321.FlagA value 'false'; +alter settings constant @BugTest1321.Label value 'true'; diff --git a/mdl/executor/cmd_constant_boolean_default_test.go b/mdl/executor/cmd_constant_boolean_default_test.go new file mode 100644 index 000000000..cce4c426f --- /dev/null +++ b/mdl/executor/cmd_constant_boolean_default_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// A Boolean constant's DefaultValue is stored as "True" / "False" — that is what +// Studio Pro writes, and its constant dialog reads a stored "true" as False while +// the runtime reads it as true (mendixlabs/mxcli#1321). The literal `true` reached +// the backend as Go's fmt rendering "true"; only the quoted 'True' was right. + +var booleanConstantDefaults = []struct { + name, mdl, want string +}{ + {"lower true", "true", "True"}, + {"title True", "True", "True"}, + {"lower false", "false", "False"}, + {"upper FALSE", "FALSE", "False"}, + {"quoted lower", "'true'", "True"}, + // Already in Studio Pro's form: the control. + {"quoted True", "'True'", "True"}, +} + +func TestCreateConstant_BooleanDefaultStoredAsStudioProCase(t *testing.T) { + for _, tc := range booleanConstantDefaults { + t.Run(tc.name, func(t *testing.T) { + mod := mkModule("MyModule") + var captured *model.Constant + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListConstantsFunc: func() ([]*model.Constant, error) { return nil, nil }, + CreateConstantFunc: func(c *model.Constant) error { + captured = c + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + + prog := parseMDL(t, "create constant MyModule.Flag (Type: Boolean, DefaultValue: "+tc.mdl+");") + if err := createConstant(ctx, prog.Statements[0].(*ast.CreateConstantStmt)); err != nil { + t.Fatalf("createConstant: %v", err) + } + if captured == nil { + t.Fatal("CreateConstant was not called") + } + if captured.DefaultValue != tc.want { + t.Errorf("DefaultValue: %s stored as %q, want %q", tc.mdl, captured.DefaultValue, tc.want) + } + }) + } +} + +// `create or modify` rewrites an existing constant through UpdateConstant — the +// second write path the same value takes. +func TestCreateOrModifyConstant_BooleanDefaultStoredAsStudioProCase(t *testing.T) { + mod := mkModule("MyModule") + existing := &model.Constant{ + ContainerID: mod.ID, + Name: "Flag", + Type: model.ConstantDataType{Kind: "Boolean"}, + DefaultValue: "False", + } + var captured *model.Constant + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListConstantsFunc: func() ([]*model.Constant, error) { return []*model.Constant{existing}, nil }, + UpdateConstantFunc: func(c *model.Constant) error { + captured = c + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + + prog := parseMDL(t, "create or modify constant MyModule.Flag (Type: Boolean, DefaultValue: true);") + _ = createConstant(ctx, prog.Statements[0].(*ast.CreateConstantStmt)) // folder handling may warn; the write is under test + if captured == nil { + t.Fatal("UpdateConstant was not called") + } + if captured.DefaultValue != "True" { + t.Errorf("DefaultValue: true stored as %q, want %q", captured.DefaultValue, "True") + } +} + +// A configuration override of a Boolean constant is stored the same way as its +// default, and `alter settings constant @M.Flag value true` wrote the token text +// "true" verbatim — the #1321 defect on the second write path. Only a Boolean +// constant is normalised: a String constant holding the text 'true' is left alone. +func TestAlterSettingsConstant_BooleanValueStoredAsStudioProCase(t *testing.T) { + cases := []struct { + name, constantID, value, want string + existing bool + }{ + {"new override true", "Mod.Flag", "true", "True", false}, + {"new override FALSE", "Mod.Flag", "FALSE", "False", false}, + {"existing override true", "Mod.Flag", "true", "True", true}, + // Already in Studio Pro's form: the control. + {"already True", "Mod.Flag", "True", "True", false}, + // Not a Boolean constant: must pass through unchanged. + {"string constant", "Mod.Label", "true", "true", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mod := mkModule("Mod") + cfg := &model.ServerConfiguration{Name: "Default"} + if tc.existing { + cfg.ConstantValues = []*model.ConstantValue{{ConstantId: tc.constantID, Value: "False"}} + } + var wrote *model.ProjectSettings + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListConstantsFunc: func() ([]*model.Constant, error) { + return []*model.Constant{ + {ContainerID: mod.ID, Name: "Flag", Type: model.ConstantDataType{Kind: "Boolean"}}, + {ContainerID: mod.ID, Name: "Label", Type: model.ConstantDataType{Kind: "String"}}, + }, nil + }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + ps := &model.ProjectSettings{ + Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{cfg}, + }, + } + ps.RawParts = []map[string]any{{"$Type": "Settings$ConfigurationSettings"}} + return ps, nil + }, + UpdateProjectSettingsFunc: func(ps *model.ProjectSettings) error { + wrote = ps + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + + if err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "constant", + ConfigName: "Default", + ConstantId: tc.constantID, + Value: tc.value, + }); err != nil { + t.Fatalf("alterSettings: %v", err) + } + if wrote == nil { + t.Fatal("UpdateProjectSettings was not called") + } + var got *model.ConstantValue + for _, cv := range wrote.Configuration.Configurations[0].ConstantValues { + if cv.ConstantId == tc.constantID { + got = cv + } + } + if got == nil { + t.Fatalf("no override for %s written", tc.constantID) + } + if got.Value != tc.want { + t.Errorf("value %s stored as %q, want %q", tc.value, got.Value, tc.want) + } + }) + } +} diff --git a/mdl/executor/cmd_constants.go b/mdl/executor/cmd_constants.go index 5cad44f59..abbfcdb4e 100644 --- a/mdl/executor/cmd_constants.go +++ b/mdl/executor/cmd_constants.go @@ -264,6 +264,22 @@ func formatDefaultValue(dt model.ConstantDataType, value string) string { } } +// storedConstantDefault puts a default value into the form Studio Pro stores. A +// Boolean is "True" / "False": Studio Pro's constant dialog reads a stored "true" as +// False while the runtime reads it as true, so the literal `true` (rendered by fmt +// as "true") made the app run with a value the developer never sees (#1321). +func storedConstantDefault(dt model.ConstantDataType, value string) string { + if dt.Kind == "Boolean" { + switch { + case strings.EqualFold(value, "true"): + return "True" + case strings.EqualFold(value, "false"): + return "False" + } + } + return value +} + // createConstant handles CREATE CONSTANT command. func createConstant(ctx *ExecContext, stmt *ast.CreateConstantStmt) error { if !ctx.ConnectedForWrite() { @@ -289,6 +305,7 @@ func createConstant(ctx *ExecContext, stmt *ast.CreateConstantStmt) error { if stmt.DefaultValue != nil { defaultValue = fmt.Sprintf("%v", stmt.DefaultValue) } + defaultValue = storedConstantDefault(constType, defaultValue) // Check if constant already exists in this module existingConstants, err := ctx.Backend.ListConstants() diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index c70653822..8f530c071 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -726,6 +726,31 @@ func alterSettingsConfiguration(ctx *ExecContext, ps *model.ProjectSettings, stm return nil } +// settingsConstantType returns the data type of the constant a configuration +// override names (Module.Name), or the zero type when it cannot be resolved — an +// unresolved constant's value is then stored as written. +func settingsConstantType(ctx *ExecContext, constantID string) model.ConstantDataType { + modName, name, ok := strings.Cut(constantID, ".") + if !ok { + return model.ConstantDataType{} + } + constants, err := ctx.Backend.ListConstants() + if err != nil { + return model.ConstantDataType{} + } + h, err := getHierarchy(ctx) + if err != nil { + return model.ConstantDataType{} + } + for _, c := range constants { + if strings.EqualFold(c.Name, name) && + strings.EqualFold(h.GetModuleName(h.FindModuleID(c.ContainerID)), modName) { + return c.Type + } + } + return model.ConstantDataType{} +} + func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *ast.AlterSettingsStmt) error { if ps.Configuration == nil { return mdlerrors.NewNotFound("settings section", "configuration") @@ -769,6 +794,9 @@ func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *as return mdlerrors.NewNotFoundMsg("constant", stmt.ConstantId, fmt.Sprintf("constant '%s' not found in configuration '%s'", stmt.ConstantId, targetConfig)) } + // A Boolean override is stored as "True" / "False", like the default (#1321). + value := storedConstantDefault(settingsConstantType(ctx, stmt.ConstantId), stmt.Value) + // Find or create the constant value found := false for _, cv := range cfg.ConstantValues { @@ -785,7 +813,7 @@ func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *as "or use `alter settings drop constant @%s in configuration '%s'` to remove the override", stmt.ConstantId, targetConfig, stmt.ConstantId, targetConfig) } - cv.Value = stmt.Value + cv.Value = value found = true break } @@ -793,7 +821,7 @@ func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *as if !found { cv := &model.ConstantValue{ ConstantId: stmt.ConstantId, - Value: stmt.Value, + Value: value, } cv.TypeName = "Settings$ConstantValue" cfg.ConstantValues = append(cfg.ConstantValues, cv) @@ -804,7 +832,7 @@ func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *as } ctx.reportWrite(fmt.Sprintf("constant '%s' in configuration '%s'", stmt.ConstantId, targetConfig), - "Updated constant '%s' = '%s' in configuration '%s'", stmt.ConstantId, stmt.Value, targetConfig) + "Updated constant '%s' = '%s' in configuration '%s'", stmt.ConstantId, value, targetConfig) return nil }