diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index da6f88926..73236ccbd 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -328,6 +328,7 @@ cases for these three BSON types — they fell to `default: return nil`. | A **Decimal/DateTime** in `dynamictext` always rendered with the hardcoded default format ("5068.38000000"); no MDL way to set the per-parameter Format, and a widget-level `decimalPrecision:` was **silently dropped** (mxcli check ✓, exec ✓, but gone) | The model always stored `ClientTemplateParameter.FormattingInfo`, but **all three writers hardcoded it** (`DecimalPrecision:2, GroupDigits:false, DateFormat:Date, EnumFormat:Text`) and ignored `param.FormattingInfo`; the grammar had no syntax to set it and DESCRIBE dropped it on read too | grammar `mdl/grammar/domains/MDLPage.g4` (`paramAssignmentV3` + `paramFormatV3`), `mdl/ast/ast_page_v3.go` (`ParamFormatV3`), `mdl/visitor/visitor_page_v3.go` (`buildParamFormatV3`), builder `mdl/executor/cmd_pages_builder_v3_widgets.go` (`formattingInfoFromParamFormat`), writers `mdl/backend/modelsdk/widget_write.go` (`formattingInfoToGen`) + `sdk/mpr/writer_widgets.go` (`serializeClientTemplateParameter`), describe `cmd_pages_describe_output.go` (`formatParamFormatSuffix`), validate `validate_widgets.go` (`validateDynamicTextFormatting`/MDL-WIDGET18) | Add a per-param `FORMAT (decimalPrecision: N, groupDigits: bool, dateFormat: …, customDateFormat: '…', enumFormat: …)` block: `{1} = Amount format (decimalPrecision: 2, groupDigits: true)`. The **FORMAT keyword is required** — a bare `(…)` after the value is ambiguous with a function call because `:` is OQL division in expressions. Writers use the param's FormattingInfo when set, else the same hardcoded defaults (nil → byte-identical to before, zero risk to existing widgets). MDL-WIDGET18 turns a widget-level format key into an actionable error (no more silent drop) and validates keys/enums. Verified: exec → `mx check` (11.12.1) 0 errors + DESCRIBE round-trip. Repro `mdl-examples/bug-tests/ledger-75-dynamictext-formatting.mdl`. Ledger #75 | | dynamic-text `format (…)` writes valid FormattingInfo, `mx check` ✓, but **renders unformatted** at runtime — a Decimal shows `-12` not `-12.00`, dates ignore the format | The parameter was serialized as `Expression: toString($currentObject/Attr)` (a non-String attribute was wrapped in `toString()`), and Mendix applies FormattingInfo **only to attribute-bound** params — an Expression param bypasses it. The BSON was valid but inert; `mx check` and DESCRIBE can't catch a *render* problem (the exact trap the runtime-verify skill exists for — I shipped #75 without it and the tester caught this) | `mdl/executor/cmd_pages_builder_v3.go` (`resolveTemplateAttributePathFull`, the bare-attribute branch) | Bind a bare non-String attribute as a structured **`AttributeRef`**, not a `toString()` Expression — the runtime then renders it through FormattingInfo, exactly as Studio Pro does. `toString()` was never required by mxbuild (AttributeRef for a Decimal/DateTime in a text template passes `mx check` → 0 errors). Engine read-parity holds. Note the `$param.Attr` non-String path (same function) still uses `toString()` — rarer, left for follow-up. Ledger #76 | | A **DataGrid2 dynamic-text column** (`column x (ShowContentAs: dynamicText, Content: '{1}', ContentParams: [{1} = Attr format (…)])`) fails to open with **CE0463**, *and* its `format (…)` block is silently dropped. `mxcli docker check` hides the CE0463 because it runs `mx update-widgets` first; raw `mx check` and `mxbuild --serve` (run --local) surface it | Two independent gaps in the full-page object-list column path. (1) The shared `buildClientTemplateParams` never read the parsed `p.Format`, and the column-scoped serializer `SerializeColumnClientTemplateParameter` **hardcoded** FormattingInfo — so a column param's format was dropped at both write points. (2) A dynamic-text column has no attribute and no content widgets, so `detectObjectListItemKind` classified it as the **default** kind, which has no empty-ClientTemplate rules → its `tooltip` serialized as `TextTemplate:null`. Studio Pro stores an **empty `Forms$ClientTemplate`** there (as for an attribute column), so the widget failed to load | `mdl/executor/cmd_pages_builder_v3_widgets.go` (`buildClientTemplateParams` → apply `formattingInfoFromParamFormat(p.Format)`), `mdl/backend/widgetobj/builder.go` (`SerializeColumnClientTemplateParameter` honours `param.FormattingInfo`; new `itemKindDynamicText` + `emptyClientTemplateRules` tooltip entry; `detectObjectListItemKind`), describe `mdl/executor/cmd_pages_describe_pluggable.go` (`extractTextTemplateParameters` zips the format suffix) | Route the FORMAT block through the *shared* params helper (fixes the object-list column path **and** the ALTER PAGE column path at once) and stop hardcoding FormattingInfo in the column serializer. Classify a `showContentAs: dynamicText` column as its own item kind and give it the attribute column's `tooltip → empty CT` rule (exportValue stays null). **Diagnosis method that found the CE0463**: `update-widgets` on a *copy* cleared it → Case B (our BSON); a path-level flatten-diff of the datagrid subtree, mine vs the reconciled reference, isolated the single differing path `columns[dynamicText]/tooltip/TextTemplate` null↔empty. **Trap**: `docker check`'s built-in `update-widgets` masks the very defect you're hunting — measure with raw `mx check` or the serve build. Repro `mdl-examples/bug-tests/ledger-77-datagrid-dynamictext-column.mdl`; verified end-to-end (raw `mx check` 0 errors + Playwright cell renders `-1,234.50`). Ledger #77 | +| 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 | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before diff --git a/.claude/skills/mendix/write-workflows.md b/.claude/skills/mendix/write-workflows.md index da27bcb90..0c2549faf 100644 --- a/.claude/skills/mendix/write-workflows.md +++ b/.claude/skills/mendix/write-workflows.md @@ -69,10 +69,10 @@ begin -- Call a microflow (server logic); optional parameter mapping + outcomes call microflow Module.ACT_Validate - with (Module.ACT_Validate.Item = '$workflowContext'); + with (Module.ACT_Validate.Item = '$WorkflowContext'); -- Decision: a boolean or enum exclusive split - decision '$workflowContext/Total > 1000' + decision '$WorkflowContext/Total > 1000' outcomes true -> { call microflow Module.ACT_Escalate; } false -> { call microflow Module.ACT_AutoApprove; }; @@ -182,6 +182,9 @@ documented in `system-module.md`. - A user task / decision with a single outcome and no activity can trip `CE1876` — give each branch a body or a distinct outcome. - The context **Parameter entity must be persistent**. +- Write the context variable as **`$WorkflowContext`**, matching the parameter + name exactly. Mendix expressions are case-sensitive on 11.9+, so a lowercase + `$workflowContext` is an undefined variable and yields `CE0117`. ## Validate before presenting diff --git a/mdl-examples/bug-tests/845-workflow-decision-context-casing.mdl b/mdl-examples/bug-tests/845-workflow-decision-context-casing.mdl new file mode 100644 index 000000000..eab949f4b --- /dev/null +++ b/mdl-examples/bug-tests/845-workflow-decision-context-casing.mdl @@ -0,0 +1,47 @@ +-- Bug #845: workflow DECISION expressions were not case-normalized. +-- +-- The workflow context parameter is stored as "WorkflowContext". Mendix +-- expressions are case-sensitive on 11.9+, so a user-written `$workflowContext` +-- is an undefined variable. CALL MICROFLOW `WITH` expressions were already +-- normalized (FINDINGS #39), but DECISION expressions were stored verbatim: +-- +-- "Expression": "$workflowContext/IsExclusive" +-- +-- mx check then reported, against an otherwise clean project: +-- +-- [error] [CE0117] "Error(s) in expression." at Decision 'Decision' +-- +-- The inconsistency is what hid the bug — the same spelling works in a WITH +-- clause and fails in a DECISION. The shipped write-workflows skill documented +-- the lowercase form in its DECISION example, so following the docs produced a +-- project that would not build. +-- +-- Fix: buildExclusiveSplit (and its sibling buildWaitForTimer, whose delay may +-- reference a context date attribute) run the authored expression through +-- normalizeWorkflowContextExpr, the same helper autoBindCallMicroflow uses. +-- +-- Manual verification (needs a project, so `make check-mdl` only syntax-checks this): +-- +-- mxcli exec 845-workflow-decision-context-casing.mdl -p app.mpr +-- mx check -p app.mpr +-- +-- Expect 0 errors. Before the fix this produced CE0117 on the decision. + +create module Issue845; +create module role Issue845.User; + +@position(100, 100) +create persistent entity Issue845.Ctx ( + IsExclusive: boolean, + Total: decimal +); + +-- Lowercase on purpose: this is the form the docs used to show. +create workflow Issue845."WF_DecisionCasing" + parameter $WorkflowContext: Issue845.Ctx +begin + decision '$workflowContext/IsExclusive' + outcomes + true -> { } + false -> { }; +end workflow; diff --git a/mdl/executor/cmd_workflows_decision_test.go b/mdl/executor/cmd_workflows_decision_test.go new file mode 100644 index 000000000..aae6c9904 --- /dev/null +++ b/mdl/executor/cmd_workflows_decision_test.go @@ -0,0 +1,46 @@ +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestBuildExclusiveSplit_NormalizesWorkflowContext guards issue #845. +// +// The workflow context parameter is stored as "WorkflowContext", and Mendix +// expressions are case-sensitive on 11.9+, so a user-written `$workflowContext` +// is an undefined variable. autoBindCallMicroflow already normalizes CALL +// MICROFLOW `WITH` expressions (FINDINGS #39), but DECISION expressions were +// stored verbatim, so the lowercase form reached the .mpr and mx check reported +// +// [error] [CE0117] "Error(s) in expression." at Decision 'Decision' +// +// The inconsistency is what made this hard to spot: the same `$workflowContext` +// spelling works in a WITH clause and fails in a DECISION. +func TestBuildExclusiveSplit_NormalizesWorkflowContext(t *testing.T) { + cases := map[string]string{ + "$workflowContext/IsExclusive": "$WorkflowContext/IsExclusive", + "$WORKFLOWCONTEXT/Total > 100": "$WorkflowContext/Total > 100", + "$WorkflowContext/IsExclusive": "$WorkflowContext/IsExclusive", + "$Other/Field": "$Other/Field", + } + for in, want := range cases { + act := buildExclusiveSplit(&ast.WorkflowDecisionNode{Expression: in}) + if act.Expression != want { + t.Errorf("buildExclusiveSplit(%q).Expression = %q, want %q", in, act.Expression, want) + } + } +} + +// TestBuildWaitForTimer_NormalizesWorkflowContext covers the same defect in the +// sibling expression site: a WAIT FOR TIMER delay may reference a date attribute +// on the workflow context, and was likewise stored verbatim. +func TestBuildWaitForTimer_NormalizesWorkflowContext(t *testing.T) { + act := buildWaitForTimer(&ast.WorkflowWaitForTimerNode{ + DelayExpression: "$workflowContext/DueDate", + }) + if want := "$WorkflowContext/DueDate"; act.DelayExpression != want { + t.Errorf("buildWaitForTimer.DelayExpression = %q, want %q", act.DelayExpression, want) + } +} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 1cad00368..2247c35fe 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -355,7 +355,10 @@ func buildCallWorkflowActivity(n *ast.WorkflowCallWorkflowNode) *workflows.CallW func buildExclusiveSplit(n *ast.WorkflowDecisionNode) *workflows.ExclusiveSplitActivity { act := &workflows.ExclusiveSplitActivity{} act.ID = model.ID(generateWorkflowUUID()) - act.Expression = n.Expression + // Same case-sensitivity trap as CALL MICROFLOW parameter mappings (#845): + // the context parameter is named "WorkflowContext", so a user-written + // `$workflowContext` is an undefined variable and mx check reports CE0117. + act.Expression = normalizeWorkflowContextExpr(n.Expression) act.Caption = n.Caption if act.Caption == "" { @@ -459,7 +462,8 @@ func buildJumpTo(n *ast.WorkflowJumpToNode) *workflows.JumpToActivity { func buildWaitForTimer(n *ast.WorkflowWaitForTimerNode) *workflows.WaitForTimerActivity { act := &workflows.WaitForTimerActivity{} act.ID = model.ID(generateWorkflowUUID()) - act.DelayExpression = n.DelayExpression + // A delay may reference a date attribute on the workflow context (#845). + act.DelayExpression = normalizeWorkflowContextExpr(n.DelayExpression) act.Caption = n.Caption if act.Caption == "" {