From 3a319269164b82a30ce267eaffea79cd8c0ed47a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:52:51 +0000 Subject: [PATCH 01/25] fix(executor): judge one-hop variable comparands in widget data source XPath (mendixlabs/mxcli#1466) After MDL055 refused two hops, two one-hop comparands still passed check and failed mxbuild with CE0161: a hop landing on an entity unrelated to the left side's (MDL-XPATH02, also association vs attribute) and an unqualified comparand association (MDL-XPATH01, page arm). Runs in the no-project tier from the script's declarations, the project filling in the rest; silent where an entity or generalization chain cannot be established. Stubbing it fails 9 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKmbj53BC2vuUWYmgWgfQ5 --- ...h-comparand-type-and-combobox-caption.json | 1 + .../skills/mendix/xpath-constraints/SKILL.md | 14 + ...datasource-xpath-comparand-entity.fail.mdl | 44 +++ ...ource-xpath-comparand-unqualified.fail.mdl | 40 ++ ...datasource-xpath-variable-traversal-ok.mdl | 33 +- .../validate_datasource_xpath_comparand.go | 351 ++++++++++++++++++ ...alidate_datasource_xpath_comparand_test.go | 175 +++++++++ mdl/executor/validate_program.go | 6 + .../validate_widget_attribute_type.go | 9 + 9 files changed, 670 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-15-widget-datasource-xpath-comparand-type-and-combobox-caption.json create mode 100644 mdl-examples/bug-tests/1466-datasource-xpath-comparand-entity.fail.mdl create mode 100644 mdl-examples/bug-tests/1466-datasource-xpath-comparand-unqualified.fail.mdl create mode 100644 mdl/executor/validate_datasource_xpath_comparand.go create mode 100644 mdl/executor/validate_datasource_xpath_comparand_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-widget-datasource-xpath-comparand-type-and-combobox-caption.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-widget-datasource-xpath-comparand-type-and-combobox-caption.json new file mode 100644 index 0000000000..558d566c3e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-widget-datasource-xpath-comparand-type-and-combobox-caption.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-15", "symptom": "After MDL055 learned the two-hop form, three combo box shapes from mendixlabs/mxcli#1466 still passed `mxcli check` and `exec` and failed mxbuild: a one-hop comparand landing on another entity than the left side (`[Mod.District_Region = $currentObject/Mod.AreaPicker_City]`) and an unqualified comparand association (`$currentObject/AreaPicker_Region`), both CE0161 \"Error(s) in XPath constraint.\", and an association-mode combo box with no caption (the issue's actual script), CE0642 \"Property 'Caption' is required.\"", "cause": "No rule judged the TYPE of a one-hop variable comparand — MDL055 counts hops only, and MDL-XPATH01 ran over microflow retrieves only; MDL-WIDGET16 returned clean as soon as an association-mode combo box had a data source, never asking for the caption that mode requires", "file": "`mdl/executor/validate_datasource_xpath_comparand.go` (`ValidateDatasourceXPathComparands`, MDL-XPATH02 + the page arm of MDL-XPATH01, wired in `ValidateProgram`), `mdl/executor/validate_widgets.go` (`validateComboBoxCaption` under MDL-WIDGET16), `memberTypeIndex` now buildable from the script alone (`overlayScriptMembers`)", "insight": "**Give every page of a multi-page measurement project its own widget NAME.** mxbuild's message carries only the widget name (`at Combo box 'cb'`), so sixteen pages with a `cb` each gave eleven unattributable errors; naming each widget after its page made one `mx check` answer the whole matrix. Measured identically on fresh 11.13.0 and 11.14.0: a comparand landing on a specialization OR a generalization of the left side's entity builds clean (so the type rule compares generalization chains, and stays silent when a chain runs through an entity it does not hold), association-vs-attribute is CE0161 too, and the caption is whichever one the caption TYPE selects — `optionsSourceAssociationCaptionType: expression` with only `CaptionAttribute:` is CE0642, and so is an expression without the type; enumeration mode needs no caption. A data source alone selects association mode, so the `Attribute:` spelling of the association needs the caption as well — reading `Association:` first would have missed it. The type rule runs in the no-project tier from the script's declarations (the issue is one script declaring everything), with -p filling in the rest; putting it in the --references walk would have left `mxcli check script.mdl` silent on the reported shape. No language-version gate: each refused form already fails the build.", "refs": ["mendixlabs/mxcli#1466", "#831"], "ce": ["CE0161", "CE0642"], "rules": ["MDL-XPATH01", "MDL-XPATH02", "MDL-WIDGET16"]} diff --git a/.claude/skills/mendix/xpath-constraints/SKILL.md b/.claude/skills/mendix/xpath-constraints/SKILL.md index 46b1b869d7..c891bc7c9b 100644 --- a/.claude/skills/mendix/xpath-constraints/SKILL.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -200,8 +200,22 @@ where [Module.Assoc/Module.Entity/Name = $Variable/Name] -- Variable on right side where [Name = $currentObject/SearchString] + +-- An association compared with ONE hop off a variable — the hop must land on +-- the same entity as the left side (or a generalization/specialization of it) +where [Module.District_Region = $currentObject/Module.AreaPicker_Region] ``` +> **What a variable comparand may be (CE0161, measured on 11.13.0 and 11.14.0).** +> At most one hop off the variable (`$currentObject/Mod.Assoc/Name` is **MDL055**). +> That one hop compares like with like: an association path with an association +> landing on the **same entity** — `[Mod.District_Region = $currentObject/Mod.AreaPicker_City]` +> (Region vs City) is CE0161 and **MDL-XPATH02**, as is comparing an association +> with an attribute (`[Mod.District_Region = $currentObject/Label]`). The hop is +> written **qualified**: `$currentObject/AreaPicker_Region` reads as an attribute +> and is CE0161 / **MDL-XPATH01**. In a widget data source `$currentObject` is the +> enclosing data container's object; a page parameter (`$Picker/…`) works the same. + ### Nested Predicates Filter intermediate path steps with inline `[predicate]`: diff --git a/mdl-examples/bug-tests/1466-datasource-xpath-comparand-entity.fail.mdl b/mdl-examples/bug-tests/1466-datasource-xpath-comparand-entity.fail.mdl new file mode 100644 index 0000000000..943c103b40 --- /dev/null +++ b/mdl-examples/bug-tests/1466-datasource-xpath-comparand-entity.fail.mdl @@ -0,0 +1,44 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1466 — one-hop comparand landing on the wrong entity +-- ============================================================================ +-- +-- NEGATIVE TEST (.fail.mdl): `mxcli check` MUST reject this file +-- (MDL-XPATH02). An unexpected pass means the comparand type check has +-- regressed. +-- +-- One hop off $currentObject is valid (MDL055 refuses two), so what is left to +-- get wrong is what the hop lands on. District_Region leads to Region, +-- AreaPicker_City to City; on fresh 11.13.0 and 11.14.0 projects: +-- +-- [error] [CE0161] "Error(s) in XPath constraint." at Combo box 'cbDistrict' +-- +-- The same comparand landing on Region, or on a specialization or a +-- generalization of it, builds clean — see +-- 1466-datasource-xpath-variable-traversal-ok.mdl. +-- ============================================================================ + +create module Bug1466E; +create persistent entity Bug1466E.Region (Name: String(100)); +create persistent entity Bug1466E.City (Name: String(100)); +create persistent entity Bug1466E.District (Name: String(100)); +create persistent entity Bug1466E.AreaPicker (Label: String(50)); +create association Bug1466E.District_Region from Bug1466E.District to Bug1466E.Region type Reference; +create association Bug1466E.AreaPicker_City from Bug1466E.AreaPicker to Bug1466E.City type Reference; +create association Bug1466E.AreaPicker_District from Bug1466E.AreaPicker to Bug1466E.District type Reference; + +create page Bug1466E.WrongEntity ( + Params: ($Picker: Bug1466E.AreaPicker), Title: 'Area', Layout: Atlas_Core.Atlas_Default +) { + layoutgrid lg { + row { + column (desktopwidth: autofill) { + dataview dvPicker (DataSource: $Picker) { + combobox cbDistrict (Association: Bug1466E.AreaPicker_District, + DataSource: database Bug1466E.District where [Bug1466E.District_Region = $currentObject/Bug1466E.AreaPicker_City], + CaptionAttribute: Name) + } + } + } + } +}; diff --git a/mdl-examples/bug-tests/1466-datasource-xpath-comparand-unqualified.fail.mdl b/mdl-examples/bug-tests/1466-datasource-xpath-comparand-unqualified.fail.mdl new file mode 100644 index 0000000000..ae478e6935 --- /dev/null +++ b/mdl-examples/bug-tests/1466-datasource-xpath-comparand-unqualified.fail.mdl @@ -0,0 +1,40 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1466 — one-hop comparand naming its association unqualified +-- ============================================================================ +-- +-- NEGATIVE TEST (.fail.mdl): `mxcli check` MUST reject this file +-- (MDL-XPATH01, the page-datasource arm of the retrieve rule). An unexpected +-- pass means the comparand check has regressed. +-- +-- `$currentObject/AreaPicker_Region` reads as an ATTRIBUTE of AreaPicker, and +-- there is none, so on fresh 11.13.0 and 11.14.0 projects: +-- +-- [error] [CE0161] "Error(s) in XPath constraint." at Combo box 'cbDistrict' +-- +-- `$currentObject/Bug1466U.AreaPicker_Region`, qualified, builds clean. +-- ============================================================================ + +create module Bug1466U; +create persistent entity Bug1466U.Region (Name: String(100)); +create persistent entity Bug1466U.District (Name: String(100)); +create persistent entity Bug1466U.AreaPicker (Label: String(50)); +create association Bug1466U.District_Region from Bug1466U.District to Bug1466U.Region type Reference; +create association Bug1466U.AreaPicker_Region from Bug1466U.AreaPicker to Bug1466U.Region type Reference; +create association Bug1466U.AreaPicker_District from Bug1466U.AreaPicker to Bug1466U.District type Reference; + +create page Bug1466U.Unqualified ( + Params: ($Picker: Bug1466U.AreaPicker), Title: 'Area', Layout: Atlas_Core.Atlas_Default +) { + layoutgrid lg { + row { + column (desktopwidth: autofill) { + dataview dvPicker (DataSource: $Picker) { + combobox cbDistrict (Association: Bug1466U.AreaPicker_District, + DataSource: database Bug1466U.District where [Bug1466U.District_Region = $currentObject/AreaPicker_Region], + CaptionAttribute: Name) + } + } + } + } +}; diff --git a/mdl-examples/bug-tests/1466-datasource-xpath-variable-traversal-ok.mdl b/mdl-examples/bug-tests/1466-datasource-xpath-variable-traversal-ok.mdl index b6abcceb3d..f9d0f17f4a 100644 --- a/mdl-examples/bug-tests/1466-datasource-xpath-variable-traversal-ok.mdl +++ b/mdl-examples/bug-tests/1466-datasource-xpath-variable-traversal-ok.mdl @@ -11,17 +11,33 @@ mdl 1; -- [Mod.A/Mod.E/Mod.B = $currentObject] the inverted form -- [Name = $currentObject/Label] the context object's own attribute -- --- The two-hop form (`$currentObject/Mod.B/Name`) is the CE0161 one, in --- 1466-datasource-xpath-variable-traversal.fail.mdl. +-- [Mod.A = $currentObject/Mod.S] one hop onto a SPECIALIZATION of A's target +-- [Mod.AS = $currentObject/Mod.B] A's target specializes the comparand's +-- +-- and, for the combo box caption (MDL-WIDGET16, CE0642 when missing): +-- +-- CaptionAttribute: Name association mode +-- optionsSourceAssociationCaptionType: expression + expr association mode +-- no caption at all ENUMERATION mode +-- +-- The CE0161 forms are in the .fail.mdl files beside this one: two hops +-- (…-variable-traversal.fail.mdl, MDL055), a comparand on an unrelated entity +-- (…-comparand-entity.fail.mdl, MDL-XPATH02) and an unqualified comparand +-- association (…-comparand-unqualified.fail.mdl, MDL-XPATH01); the CE0642 one +-- is 1466-combobox-caption-required.fail.mdl. -- ============================================================================ create module Bug1466; create persistent entity Bug1466.Region (Name: String(100)); create persistent entity Bug1466.District (Name: String(100)); -create persistent entity Bug1466.AreaPicker (Label: String(50)); +create persistent entity Bug1466.SubRegion extends Bug1466.Region (Extra: String(10)); +create enumeration Bug1466.Kind (Near 'Near', Far 'Far'); +create persistent entity Bug1466.AreaPicker (Label: String(50), Kind: Enumeration(Bug1466.Kind)); create association Bug1466.District_Region from Bug1466.District to Bug1466.Region type Reference; create association Bug1466.AreaPicker_Region from Bug1466.AreaPicker to Bug1466.Region type Reference; create association Bug1466.AreaPicker_District from Bug1466.AreaPicker to Bug1466.District type Reference; +create association Bug1466.AreaPicker_SubRegion from Bug1466.AreaPicker to Bug1466.SubRegion type Reference; +create association Bug1466.District_SubRegion from Bug1466.District to Bug1466.SubRegion type Reference; create page Bug1466.Controls ( Params: ($Picker: Bug1466.AreaPicker), Title: 'Area', Layout: Atlas_Core.Atlas_Default @@ -39,6 +55,17 @@ create page Bug1466.Controls ( combobox cbOwnAttr (Association: Bug1466.AreaPicker_District, DataSource: database Bug1466.District where [Name = $currentObject/Label], CaptionAttribute: Name) + combobox cbSpecialization (Association: Bug1466.AreaPicker_District, + DataSource: database Bug1466.District where [Bug1466.District_Region = $currentObject/Bug1466.AreaPicker_SubRegion], + CaptionAttribute: Name) + combobox cbGeneralization (Association: Bug1466.AreaPicker_District, + DataSource: database Bug1466.District where [Bug1466.District_SubRegion = $currentObject/Bug1466.AreaPicker_Region], + CaptionAttribute: Name) + combobox cbExprCaption (Association: Bug1466.AreaPicker_District, + DataSource: database Bug1466.District, + optionsSourceAssociationCaptionType: expression, + optionsSourceAssociationCaptionExpression: '$currentObject/Name') + combobox cbEnum (Attribute: Kind) listview lv (DataSource: database from Bug1466.District where [Bug1466.District_Region = $currentObject/Bug1466.AreaPicker_Region]) { dynamictext t (Content: '{1}', ContentParams: ({1} = Name)) } diff --git a/mdl/executor/validate_datasource_xpath_comparand.go b/mdl/executor/validate_datasource_xpath_comparand.go new file mode 100644 index 0000000000..63919dd5ee --- /dev/null +++ b/mdl/executor/validate_datasource_xpath_comparand.go @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// A widget data source constraint comparing an association with a ONE-hop +// comparand off a variable — `[Mod.District_Region = $currentObject/Mod.X]` — +// that mxbuild rejects although the hop count is fine (mendixlabs/mxcli#1466). +// MDL055 already refuses two hops; one hop is valid, so the comparand's TYPE is +// what is left to get wrong. Measured on fresh 11.13.0 and 11.14.0 projects +// (identical), one page each, in a combo box and a list view: +// +// [Mod.District_Region = $currentObject/Mod.AreaPicker_Region] ok Region = Region +// … = $currentObject/Mod.AreaPicker_SubRegion ok a specialization +// [Mod.District_SubRegion = $currentObject/Mod.AreaPicker_Region] ok a generalization +// [Mod.District_Region = $currentObject/Mod.AreaPicker_City] CE0161 Region vs City +// [Mod.District_Region = $Picker/Mod.AreaPicker_City] CE0161 page parameter root +// [Mod.District_Region = $currentObject/Label] CE0161 object vs attribute +// [Mod.District_Region = $currentObject/AreaPicker_Region] CE0161 unqualified association +// +// mxbuild's message is the same for all of them: `"Error(s) in XPath +// constraint." at Combo box 'cb'`. The two rules here say which mistake it is: +// +// - MDL-XPATH02: the two sides are objects of unrelated entities, or one side +// is an object and the other an attribute value; +// - MDL-XPATH01: the comparand names an association unqualified — the +// page-datasource arm of the retrieve rule of that name. +// +// It runs without a project, because the issue's shape is ONE script declaring +// the entities, the associations and the page; with -p the project answers for +// what the script does not declare. Anything this cannot establish — the +// enclosing entity, a hop, a generalization chain through an entity neither +// knows — is silence, not a finding. Not gated on the language version: each +// refused form already fails the build, so no script that builds changes +// meaning (the same reasoning as MDL055's page arm). + +const xpathComparandTypeRule = "MDL-XPATH02" + +// ValidateDatasourceXPathComparands judges the one-hop comparands of every +// database data source constraint the script's pages and snippets carry. +func ValidateDatasourceXPathComparands(prog *ast.Program, project func() backend.FullBackend) []linter.Violation { + if prog == nil || !programHasVariableComparand(prog) { + return nil + } + sc := newScriptContext() + sc.collectDefinitions(prog) + var ctx *ExecContext + if project != nil { + if b := project(); b != nil { + ctx = &ExecContext{Backend: b} + } + } + sigs := map[string]*flowSignature{} + if ctx != nil { + sigs = buildFlowSignatures(ctx) + } + for name, sig := range sc.flowParams { + sigs[name] = sig + } + v := &comparandValidator{types: checkMemberTypeIndex(ctx, sc), sigs: sigs} + + for _, stmt := range prog.Statements { + var doc string + var params []ast.PageParameter + var roots []*ast.WidgetV3 + switch s := stmt.(type) { + case *ast.CreatePageStmtV3: + doc, params, roots = "page "+s.Name.String(), s.Parameters, allPageWidgets(s) + case *ast.CreateSnippetStmtV3: + doc, params, roots = "snippet "+s.Name.String(), s.Parameters, s.Widgets + default: + continue + } + v.doc = doc + v.pageParams = map[string]string{} + for _, p := range params { + if qn := p.EntityType.String(); qn != "" && qn != "." { + v.pageParams[strings.ToLower(p.Name)] = qn + } + } + for _, w := range roots { + v.walk(w, dataContext{}) + } + } + return v.out +} + +// programHasVariableComparand is the cheap pre-test: a database data source +// whose constraint names a variable at all. Without one nothing is indexed and +// the project is never opened. +func programHasVariableComparand(prog *ast.Program) bool { + found := false + for _, stmt := range prog.Statements { + forEachWidget(stmt, func(w *ast.WidgetV3, _ string) { + if ds := w.GetDataSource(); ds != nil && strings.EqualFold(ds.Type, "database") && strings.Contains(ds.Where, "$") { + found = true + } + }) + if found { + return true + } + } + return false +} + +type comparandValidator struct { + types memberTypeIndex + sigs map[string]*flowSignature + pageParams map[string]string + doc string + out []linter.Violation +} + +func (v *comparandValidator) walk(w *ast.WidgetV3, enclosing dataContext) { + if w == nil { + return + } + v.checkWidget(w, enclosing) + inner := childContext(enclosing, w.GetDataSource(), v.sigs, v.pageParams) + // A pluggable widget's slots each get the context their own link says, + // which this walk does not model: nothing inside one is judged. + if w.TypeIsGeneric || w.Type == "pluggablewidget" || w.Type == "customwidget" { + inner = enclosing.withUnresolved() + } + for _, c := range w.Children { + if isControlBar(c) { + v.walk(c, enclosing) + continue + } + v.walk(c, inner) + } +} + +func (v *comparandValidator) checkWidget(w *ast.WidgetV3, enclosing dataContext) { + ds := w.GetDataSource() + if ds == nil || ds.Where == "" || !strings.EqualFold(ds.Type, "database") || !strings.Contains(ds.Reference, ".") { + return + } + groups := visitor.SplitXPathPredicateGroups(ds.Where) + if len(groups) == 0 { + groups = []string{ds.Where} + } + for _, g := range groups { + expr, ok := visitor.ParseXPathConstraint(g) + if !ok || expr == nil { + continue // lenient parse: a group it cannot read is skipped, never reported + } + v.walkExpr(w, ds.Reference, enclosing, expr) + } +} + +func (v *comparandValidator) walkExpr(w *ast.WidgetV3, entity string, enclosing dataContext, expr ast.Expression) { + switch e := expr.(type) { + case *ast.BinaryExpr: + if e.Operator == "=" || e.Operator == "!=" { + if !v.checkComparison(w, entity, enclosing, e.Left, e.Right) { + v.checkComparison(w, entity, enclosing, e.Right, e.Left) + } + return + } + v.walkExpr(w, entity, enclosing, e.Left) + v.walkExpr(w, entity, enclosing, e.Right) + case *ast.ParenExpr: + v.walkExpr(w, entity, enclosing, e.Inner) + case *ast.UnaryExpr: + v.walkExpr(w, entity, enclosing, e.Operand) + } +} + +// checkComparison judges `member = $var/step`. It reports whether it emitted. +func (v *comparandValidator) checkComparison(w *ast.WidgetV3, entity string, enclosing dataContext, member, comparand ast.Expression) bool { + left, ok := v.objectPathTarget(member, entity) + if !ok { + return false + } + path, ok := comparand.(*ast.XPathPathExpr) + if !ok || len(path.Steps) != 2 || len(path.Steps[0].Predicates) > 0 || len(path.Steps[1].Predicates) > 0 { + return false // two or more hops are MDL055's + } + root, ok := path.Steps[0].Expr.(*ast.VariableExpr) + if !ok { + return false + } + varEntity := v.variableEntity(root.Name, enclosing) + if varEntity == "" { + return false + } + label := fmt.Sprintf("%s: %s", v.doc, widgetLabel(w.Name, w.Type)) + text := fmt.Sprintf("$%s/%s", root.Name, xpathStepText(path.Steps[1].Expr)) + + switch st := path.Steps[1].Expr.(type) { + case *ast.QualifiedNameExpr: + qn := st.QualifiedName.String() + _, right, ok := v.types.follow(qn, varEntity) + if !ok || v.related(left, right) { + return false + } + v.out = append(v.out, linter.Violation{ + RuleID: xpathComparandTypeRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s datasource constraint compares %s (a %s) with `%s` (a %s) — objects of unrelated entities, which mxbuild rejects with CE0161 \"Error(s) in XPath constraint.\"", + label, xpathStepText(member), left, text, right), + Suggestion: fmt.Sprintf("Compare with an association of %s that leads to %s, or change the left-hand path so it ends at %s.", + varEntity, left, right), + }) + return true + case *ast.IdentifierExpr: + name := st.Name + if xpathImplicitMembers[name] { + return false + } + _, attr, found, known := v.types.lookup(varEntity, name) + if !known { + return false + } + if found { + v.out = append(v.out, linter.Violation{ + RuleID: xpathComparandTypeRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s datasource constraint compares %s (a %s object) with `%s`, the attribute %s.%s — an association compares with an object, not an attribute value; mxbuild rejects this with CE0161 \"Error(s) in XPath constraint.\"", + label, xpathStepText(member), left, text, varEntity, attr.name), + Suggestion: fmt.Sprintf("Compare an attribute with the attribute (`[// = %s]`) or the association with an object (`[%s = $%s/]`).", + text, xpathStepText(member), root.Name), + }) + return true + } + assocQN, _, isAssoc := v.types.follow(name, varEntity) + if !isAssoc { + return false + } + v.out = append(v.out, linter.Violation{ + RuleID: xpathAssociationRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s datasource constraint names the association %s unqualified in `%s` — Mendix requires the qualified form and the build fails with CE0161 \"Error(s) in XPath constraint.\"", + label, name, text), + Suggestion: fmt.Sprintf("Write `$%s/%s` instead of `%s`.", root.Name, assocQN, text), + }) + return true + } + return false +} + +// objectPathTarget follows a member path from entity and returns the entity +// it ends at, when every step is an association or an entity cast the index +// knows. A path ending in an attribute, or with a predicate, is not judged. +func (v *comparandValidator) objectPathTarget(expr ast.Expression, entity string) (string, bool) { + var steps []ast.Expression + switch e := expr.(type) { + case *ast.QualifiedNameExpr: + steps = []ast.Expression{e} + case *ast.XPathPathExpr: + for _, st := range e.Steps { + if len(st.Predicates) > 0 { + return "", false + } + steps = append(steps, st.Expr) + } + default: + return "", false + } + cur := entity + for _, st := range steps { + q, ok := st.(*ast.QualifiedNameExpr) + if !ok { + return "", false + } + qn := q.QualifiedName.String() + if _, next, ok := v.types.follow(qn, cur); ok { + cur = next + continue + } + if _, isEntity := v.types.attrs[qn]; isEntity { + cur = qn // an entity cast + continue + } + return "", false + } + return cur, len(steps) > 0 +} + +// variableEntity is the entity `$name` holds in a widget's constraint: +// $currentObject is the enclosing data container's object, anything else a +// document parameter. "" when it cannot be established. +func (v *comparandValidator) variableEntity(name string, enclosing dataContext) string { + if strings.EqualFold(name, "currentObject") { + if enclosing.unresolved || len(enclosing.entities) == 0 { + return "" + } + return enclosing.entities[len(enclosing.entities)-1] + } + return v.pageParams[strings.ToLower(name)] +} + +// related reports whether a and b are the same entity or one generalizes the +// other — measured both ways round as building clean. It answers true (stay +// silent) whenever either chain runs through an entity the index does not hold. +func (v *comparandValidator) related(a, b string) bool { + if strings.EqualFold(a, b) { + return true + } + ca, cb := v.types.chain(a), v.types.chain(b) + for _, chain := range [][]string{ca, cb} { + for _, e := range chain { + if _, known := v.types.attrs[e]; !known { + return true + } + } + } + for _, e := range ca { + if strings.EqualFold(e, b) { + return true + } + } + for _, e := range cb { + if strings.EqualFold(e, a) { + return true + } + } + return false +} + +// xpathStepText renders a parsed member path back as written. +func xpathStepText(expr ast.Expression) string { + switch e := expr.(type) { + case *ast.QualifiedNameExpr: + return e.QualifiedName.String() + case *ast.IdentifierExpr: + return e.Name + case *ast.VariableExpr: + return "$" + e.Name + case *ast.XPathPathExpr: + parts := make([]string, 0, len(e.Steps)) + for _, st := range e.Steps { + parts = append(parts, xpathStepText(st.Expr)) + } + return strings.Join(parts, "/") + } + return "?" +} diff --git a/mdl/executor/validate_datasource_xpath_comparand_test.go b/mdl/executor/validate_datasource_xpath_comparand_test.go new file mode 100644 index 0000000000..23a5561198 --- /dev/null +++ b/mdl/executor/validate_datasource_xpath_comparand_test.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mendixlabs/mxcli#1466, the two one-hop comparands that still passed `check` +// after MDL055 learned the two-hop form. Each page below was one page in a +// fresh `mxcli new` project, built with mxbuild 11.14.0 and 11.13.0 (identical); +// a "want" rule means that page was +// +// [error] [CE0161] "Error(s) in XPath constraint." at Combo box '…' +// +// and no rule means it built at 0 errors. +const comparandDomain = ` +create module Bm; +create persistent entity Bm.Region (Name: String(100)); +create persistent entity Bm.SubRegion extends Bm.Region (Extra: String(10)); +create persistent entity Bm.District (Name: String(100)); +create persistent entity Bm.City (Name: String(100)); +create persistent entity Bm.AreaPicker (Label: String(50)); +create association Bm.District_Region from Bm.District to Bm.Region type Reference; +create association Bm.District_SubRegion from Bm.District to Bm.SubRegion type Reference; +create association Bm.City_Region from Bm.City to Bm.Region type Reference; +create association Bm.AreaPicker_Region from Bm.AreaPicker to Bm.Region type Reference; +create association Bm.AreaPicker_SubRegion from Bm.AreaPicker to Bm.SubRegion type Reference; +create association Bm.AreaPicker_District from Bm.AreaPicker to Bm.District type Reference; +create association Bm.AreaPicker_City from Bm.AreaPicker to Bm.City type Reference; +` + +func comparandPage(widget string) string { + return `create page Bm.P (Params: ($Picker: Bm.AreaPicker), Title: 'P', Layout: Atlas_Core.Atlas_Default) { + dataview dv (DataSource: $Picker) { + ` + widget + ` + } +};` +} + +func comparandCombo(where string) string { + return `combobox cb (Association: Bm.AreaPicker_District, DataSource: database Bm.District where [` + where + `], CaptionAttribute: Name)` +} + +func comparandViolations(t *testing.T, src string, project func() backend.FullBackend) []linter.Violation { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if project == nil { + project = func() backend.FullBackend { return nil } + } + return ValidateDatasourceXPathComparands(prog, project) +} + +func TestDatasourceXPathComparand_Measured(t *testing.T) { + cases := []struct { + name string + widget string + rule string // "" = builds clean + }{ + // Shape 1: the comparand lands on another entity than the left side. + {"target entity differs", comparandCombo(`Bm.District_Region = $currentObject/Bm.AreaPicker_City`), "MDL-XPATH02"}, + {"long left side, target differs", comparandCombo(`Bm.District_Region/Bm.Region/Bm.City_Region = $currentObject/Bm.AreaPicker_Region`), "MDL-XPATH02"}, + {"page parameter as the root", comparandCombo(`Bm.District_Region = $Picker/Bm.AreaPicker_City`), "MDL-XPATH02"}, + {"list view", `listview lv (DataSource: database from Bm.District where [Bm.District_Region = $currentObject/Bm.AreaPicker_City]) { dynamictext t (Content: '{1}', ContentParams: ({1} = Name)) }`, "MDL-XPATH02"}, + {"association against an attribute", comparandCombo(`Bm.District_Region = $currentObject/Label`), "MDL-XPATH02"}, + // Shape 2: the comparand's association is unqualified. + {"unqualified association", comparandCombo(`Bm.District_Region = $currentObject/AreaPicker_Region`), "MDL-XPATH01"}, + + // Controls. + {"one hop, same entity (the issue's form)", comparandCombo(`Bm.District_Region = $currentObject/Bm.AreaPicker_Region`), ""}, + {"comparand is a specialization", comparandCombo(`Bm.District_Region = $currentObject/Bm.AreaPicker_SubRegion`), ""}, + {"comparand is a generalization", comparandCombo(`Bm.District_SubRegion = $currentObject/Bm.AreaPicker_Region`), ""}, + {"long left side, same entity", comparandCombo(`Bm.District_Region/Bm.Region/Bm.City_Region = $currentObject/Bm.AreaPicker_City`), ""}, + {"inverted form", comparandCombo(`Bm.District_Region/Bm.Region/Bm.AreaPicker_Region = $currentObject`), ""}, + {"own attribute", comparandCombo(`Name = $currentObject/Label`), ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vs := comparandViolations(t, comparandDomain+comparandPage(c.widget), nil) + if c.rule == "" { + if len(vs) != 0 { + t.Fatalf("builds clean, want no violation, got %q", vs[0].Message) + } + return + } + if len(vs) != 1 || vs[0].RuleID != c.rule || vs[0].Severity != linter.SeverityError { + t.Fatalf("want one %s error, got %+v", c.rule, vs) + } + for _, want := range []string{"CE0161", "Bm.P"} { + if !strings.Contains(vs[0].Message, want) { + t.Errorf("message should mention %q, got: %s", want, vs[0].Message) + } + } + }) + } +} + +// The messages name the two entities, and the unqualified one its spelling. +func TestDatasourceXPathComparand_Messages(t *testing.T) { + vs := comparandViolations(t, comparandDomain+comparandPage(comparandCombo(`Bm.District_Region = $currentObject/Bm.AreaPicker_City`)), nil) + if len(vs) != 1 || !strings.Contains(vs[0].Message, "Bm.Region") || !strings.Contains(vs[0].Message, "Bm.City") { + t.Fatalf("mismatch should name Bm.Region and Bm.City, got %+v", vs) + } + vs = comparandViolations(t, comparandDomain+comparandPage(comparandCombo(`Bm.District_Region = $currentObject/AreaPicker_Region`)), nil) + if len(vs) != 1 || !strings.Contains(vs[0].Suggestion, "Bm.AreaPicker_Region") { + t.Fatalf("unqualified should suggest Bm.AreaPicker_Region, got %+v", vs) + } +} + +// Silence wherever an entity cannot be established. +func TestDatasourceXPathComparand_SilentWhenUnknown(t *testing.T) { + // No enclosing data container: $currentObject has no entity. + top := `create page Bm.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { + ` + comparandCombo(`Bm.District_Region = $currentObject/Bm.AreaPicker_City`) + ` +};` + if vs := comparandViolations(t, comparandDomain+top, nil); len(vs) != 0 { + t.Errorf("no enclosing context: want silence, got %q", vs[0].Message) + } + // Entities the script does not declare and no project. + other := strings.ReplaceAll(comparandPage(comparandCombo(`Bm.District_Region = $currentObject/Bm.AreaPicker_City`)), "Bm.", "Ext.") + if vs := comparandViolations(t, other, nil); len(vs) != 0 { + t.Errorf("undeclared entities: want silence, got %q", vs[0].Message) + } +} + +// The project answers for entities the script does not declare. +func TestDatasourceXPathComparand_FromProject(t *testing.T) { + mod := &model.Module{BaseElement: model.BaseElement{ID: "mod-ext"}, Name: "Ext"} + ent := func(id, name string) *domainmodel.Entity { + return &domainmodel.Entity{BaseElement: model.BaseElement{ID: model.ID(id)}, Name: name, + Attributes: []*domainmodel.Attribute{{Name: "Name", Type: &domainmodel.StringAttributeType{}}}} + } + assoc := func(name, from, to string) *domainmodel.Association { + return &domainmodel.Association{BaseElement: model.BaseElement{ID: model.ID("a-" + name)}, Name: name, + ParentID: model.ID(from), ChildID: model.ID(to)} + } + b := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ + BaseElement: model.BaseElement{ID: "dm-ext"}, ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ent("e-r", "Region"), ent("e-d", "District"), ent("e-c", "City"), ent("e-p", "AreaPicker")}, + Associations: []*domainmodel.Association{ + assoc("District_Region", "e-d", "e-r"), assoc("AreaPicker_Region", "e-p", "e-r"), + assoc("AreaPicker_District", "e-p", "e-d"), assoc("AreaPicker_City", "e-p", "e-c"), + }, + }}, nil + }, + } + project := func() backend.FullBackend { return b } + page := func(where string) string { + return strings.ReplaceAll(comparandPage(comparandCombo(where)), "Bm.", "Ext.") + } + if vs := comparandViolations(t, page(`Ext.District_Region = $currentObject/Ext.AreaPicker_City`), project); len(vs) != 1 || vs[0].RuleID != "MDL-XPATH02" { + t.Errorf("project-resolved mismatch: want one MDL-XPATH02, got %+v", vs) + } + if vs := comparandViolations(t, page(`Ext.District_Region = $currentObject/AreaPicker_Region`), project); len(vs) != 1 || vs[0].RuleID != "MDL-XPATH01" { + t.Errorf("project-resolved unqualified: want one MDL-XPATH01, got %+v", vs) + } + if vs := comparandViolations(t, page(`Ext.District_Region = $currentObject/Ext.AreaPicker_Region`), project); len(vs) != 0 { + t.Errorf("control: want silence, got %q", vs[0].Message) + } +} diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index ff47b40ef8..9e26be466d 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -201,6 +201,12 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // can say which layouts are native. violations = append(violations, ValidatePageLayoutGrid(prog, projectNativeLayouts(project))...) + // A widget data source constraint whose one-hop comparand is the wrong type + // (MDL-XPATH02) or names its association unqualified (MDL-XPATH01) — + // CE0161 at build time (mendixlabs/mxcli#1466). The script's declarations + // answer first; the project, when there is one, for the rest. + violations = append(violations, ValidateDatasourceXPathComparands(prog, project)...) + // Warn (MDL-OFFLINE01) when a page binds an attribute across more than one // association in a project that has an offline navigation profile. Mendix // rejects those with CE6206 on any page an offline profile can reach, and diff --git a/mdl/executor/validate_widget_attribute_type.go b/mdl/executor/validate_widget_attribute_type.go index 7e4aab3de0..11d6c31285 100644 --- a/mdl/executor/validate_widget_attribute_type.go +++ b/mdl/executor/validate_widget_attribute_type.go @@ -217,6 +217,10 @@ func checkMemberTypeIndex(ctx *ExecContext, sc *scriptContext) memberTypeIndex { parents: map[string]string{}, assocs: map[string][2]string{}, } + // No project (a script checked on its own): the index is the script's. + if ctx == nil || ctx.Backend == nil { + return overlayScriptMembers(ix, sc) + } if modules, err := getModulesFromCache(ctx); err == nil { moduleNames := make(map[model.ID]string, len(modules)) for _, m := range modules { @@ -260,6 +264,11 @@ func checkMemberTypeIndex(ctx *ExecContext, sc *scriptContext) memberTypeIndex { } } } + return overlayScriptMembers(ix, sc) +} + +// overlayScriptMembers lays what the script declares over the project's index. +func overlayScriptMembers(ix memberTypeIndex, sc *scriptContext) memberTypeIndex { if sc == nil { return ix } From 592ae0076b6557f2ec6c5635528ced2c9bea813a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:52:51 +0000 Subject: [PATCH 02/25] fix(executor): MDL-WIDGET16 requires an association-mode combo box caption (mendixlabs/mxcli#1466) The issue's own script omitted CaptionAttribute on both combo boxes; check and exec passed and mxbuild reported CE0642 "Property 'Caption' is required." per combo box. MDL-WIDGET16 returned clean once a data source was present. It now requires the caption the caption type selects (attribute, or type expression + expression), measured on fresh 11.13.0/11.14.0 projects; enumeration mode is not judged. Test table mirrors the measurement; stubbing the check fails 8 cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKmbj53BC2vuUWYmgWgfQ5 --- .../mendix/create-page/reference/widgets.md | 6 ++ .../1466-combobox-caption-required.fail.mdl | 45 +++++++++ .../validate_combobox_caption_test.go | 88 +++++++++++++++++ mdl/executor/validate_widgets.go | 94 +++++++++++++++++-- mdl/executor/validate_widgets_test.go | 3 +- 5 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 mdl-examples/bug-tests/1466-combobox-caption-required.fail.mdl create mode 100644 mdl/executor/validate_combobox_caption_test.go diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 46f1a47d0e..41fa1ede1e 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -551,6 +551,12 @@ combobox cbCountry (label: 'Country', attribute: Country) combobox cmbCustomer (label: 'Customer', Association: Order_Customer, datasource: database MyModule.Customer, CaptionAttribute: Name) -- WRONG: `combobox (Association: X)` with no datasource — mxcli check errors -- MDL-WIDGET16 (Mendix would otherwise drop the binding → CE0642). +-- WRONG: a datasource with no caption — also MDL-WIDGET16, CE0642 +-- "Property 'Caption' is required." An expression caption works instead of +-- CaptionAttribute, but needs BOTH keys: +-- optionsSourceAssociationCaptionType: expression, +-- optionsSourceAssociationCaptionExpression: '$currentObject/Name' +-- Enumeration mode (no datasource) has no caption and needs none. ``` ### DataView with Form Layout diff --git a/mdl-examples/bug-tests/1466-combobox-caption-required.fail.mdl b/mdl-examples/bug-tests/1466-combobox-caption-required.fail.mdl new file mode 100644 index 0000000000..c9a480e84e --- /dev/null +++ b/mdl-examples/bug-tests/1466-combobox-caption-required.fail.mdl @@ -0,0 +1,45 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1466 — association-mode combo box with no caption +-- ============================================================================ +-- +-- NEGATIVE TEST (.fail.mdl): `mxcli check` MUST reject this file +-- (MDL-WIDGET16). An unexpected pass means the caption half of the rule has +-- regressed. +-- +-- The issue's own script had no `CaptionAttribute:` on either combo box. check +-- and exec passed; on fresh 11.13.0 and 11.14.0 projects mxbuild reported, once +-- per combo box: +-- +-- [error] [CE0642] "Property 'Caption' is required." at Combo box 'cbDistrict' +-- +-- A data source is what puts a combo box in association mode, and that mode +-- needs a caption: `CaptionAttribute: `, or +-- `optionsSourceAssociationCaptionType: expression` WITH +-- `optionsSourceAssociationCaptionExpression: '…'`. Enumeration mode +-- (`Attribute: `, no data source) has no caption and needs none — +-- see 1466-datasource-xpath-variable-traversal-ok.mdl. +-- ============================================================================ + +create module Bug1466C; +create persistent entity Bug1466C.Region (Name: String(100)); +create persistent entity Bug1466C.District (Name: String(100)); +create persistent entity Bug1466C.AreaPicker (Label: String(50)); +create association Bug1466C.District_Region from Bug1466C.District to Bug1466C.Region type Reference; +create association Bug1466C.AreaPicker_Region from Bug1466C.AreaPicker to Bug1466C.Region type Reference; +create association Bug1466C.AreaPicker_District from Bug1466C.AreaPicker to Bug1466C.District type Reference; + +create page Bug1466C.NoCaption ( + Params: ($Picker: Bug1466C.AreaPicker), Title: 'Area', Layout: Atlas_Core.Atlas_Default +) { + layoutgrid lg { + row { + column (desktopwidth: autofill) { + dataview dvPicker (DataSource: $Picker) { + combobox cbDistrict (Association: Bug1466C.AreaPicker_District, + DataSource: database Bug1466C.District where [Bug1466C.District_Region = $currentObject/Bug1466C.AreaPicker_Region]) + } + } + } + } +}; diff --git a/mdl/executor/validate_combobox_caption_test.go b/mdl/executor/validate_combobox_caption_test.go new file mode 100644 index 0000000000..ab3ac2e017 --- /dev/null +++ b/mdl/executor/validate_combobox_caption_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1466: a combo box with an options data source and no caption +// passed `check` and `exec`, and mxbuild then reported +// +// [error] [CE0642] "Property 'Caption' is required." at Combo box 'cb' +// +// The issue's own script had this on both of its combo boxes. Each row below +// was one page in a fresh `mxcli new` project, built with mxbuild 11.14.0 and +// 11.13.0 (identical results); "want" is whether that page was CE0642. +func TestMDLWIDGET16_ComboBoxCaptionRequired(t *testing.T) { + cases := []struct { + name string + props string + want bool + }{ + // Association mode (the def selects it on a data source), no caption. + {"database source, no caption", `Association: Bm.AreaPicker_District, DataSource: database Bm.District`, true}, + {"microflow source, no caption", `Association: Bm.AreaPicker_District, DataSource: microflow Bm.DS_Districts`, true}, + {"Attribute: spelling of the association, no caption", `Attribute: AreaPicker_District, DataSource: database Bm.District`, true}, + {"caption type expression, no expression", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, optionsSourceAssociationCaptionType: expression`, true}, + {"caption type expression, only an attribute", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, optionsSourceAssociationCaptionType: expression, CaptionAttribute: Name`, true}, + {"expression without the type", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, optionsSourceAssociationCaptionExpression: '$currentObject/Name'`, true}, + {"caption type attribute, only an expression", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, optionsSourceAssociationCaptionType: attribute, optionsSourceAssociationCaptionExpression: '$currentObject/Name'`, true}, + + // Controls: each built at 0 errors. + {"CaptionAttribute", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, CaptionAttribute: Name`, false}, + {"named caption attribute key", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, optionsSourceAssociationCaptionAttribute: Name`, false}, + {"caption expression", `Association: Bm.AreaPicker_District, DataSource: database Bm.District, optionsSourceAssociationCaptionType: expression, optionsSourceAssociationCaptionExpression: '$currentObject/Name'`, false}, + {"enumeration mode needs no caption", `Attribute: Kind`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := parseOneComboBox(t, c.props) + var hits []string + for _, v := range validateComboBoxAssociation(w, "page Bm.P") { + if v.RuleID == "MDL-WIDGET16" { + hits = append(hits, v.Message) + } + } + if got := len(hits) > 0; got != c.want { + t.Fatalf("MDL-WIDGET16 = %v, want %v (%q)", got, c.want, hits) + } + if c.want && !strings.Contains(hits[0], "CE0642") || c.want && !strings.Contains(hits[0], "Caption") { + t.Errorf("message should cite CE0642 and the Caption property, got: %s", hits[0]) + } + }) + } +} + +func parseOneComboBox(t *testing.T, props string) *ast.WidgetV3 { + t.Helper() + src := `create page Bm.P (Params: ($Picker: Bm.AreaPicker), Title: 'P', Layout: Atlas_Core.Atlas_Default) { + dataview dv (DataSource: $Picker) { + combobox cb (` + props + `) + } +};` + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + s := prog.Statements[0].(*ast.CreatePageStmtV3) + var found *ast.WidgetV3 + var walk func(ws []*ast.WidgetV3) + walk = func(ws []*ast.WidgetV3) { + for _, w := range ws { + if strings.EqualFold(w.Type, "combobox") { + found = w + } + walk(w.Children) + } + } + walk(allPageWidgets(s)) + if found == nil { + t.Fatal("no combobox parsed") + } + return found +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index f63dd3b0da..b9a232bc87 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -423,29 +423,105 @@ func validateComboBoxAssociation(w *ast.WidgetV3, locationPrefix string) []linte if w == nil || !strings.EqualFold(w.Type, "combobox") { return nil } + // A data source is what selects association mode (the def's `hasDataSource` + // condition), whichever key names the association — `Association:` or the + // `Attribute:` spelling — so the caption is judged before either is read. + if comboBoxHasOptionsDataSource(w) { + return validateComboBoxCaption(w, locationPrefix) + } if w.GetStringProp("Association") == "" { return nil } + return []linter.Violation{{ + RuleID: "MDL-WIDGET16", + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: combobox `%s` binds an association (`Association:`) but has no `datasource:` — Mendix drops the binding and fails the build with CE0642 (\"Property 'Attribute' is required\")", + locationPrefix, w.Name), + Suggestion: "Association mode needs the option list and a caption: `combobox " + w.Name + " (Association: Module.Ref, datasource: database Module.TargetEntity, CaptionAttribute: Name)`.", + }} +} + +// comboBoxHasOptionsDataSource reports whether a ComboBox carries the option +// list of association mode. The option list may also be given under the schema +// key the widget declares for it. Reading only the generic clause made +// MDL-WIDGET16 contradict MDL-WIDGET05, which had just accepted the same value: +// one said the datasource was fine, the other that there was none. +func comboBoxHasOptionsDataSource(w *ast.WidgetV3) bool { if w.GetDataSource() != nil { - return nil // has an options datasource — association mode is complete enough + return true } - // The option list may also be given under the schema key the widget declares - // for it. Reading only the generic clause made this rule contradict - // MDL-WIDGET05, which had just accepted the same value: one said the - // datasource was fine, the other that there was none. - if _, named := lookupProperty(w.Properties, comboBoxOptionsDataSourceKey); named { + _, named := lookupProperty(w.Properties, comboBoxOptionsDataSourceKey) + return named +} + +// validateComboBoxCaption flags an association-mode ComboBox with no caption — +// MDL-WIDGET16's second half (mendixlabs/mxcli#1466). The issue's own script +// omitted `CaptionAttribute:` on both of its combo boxes; check and exec passed +// and mxbuild reported, per combo box: +// +// [error] [CE0642] "Property 'Caption' is required." at Combo box 'cb' +// +// Measured on fresh 11.13.0 and 11.14.0 projects, identical on both. The +// caption the build reads is the one the caption TYPE selects: +// +// no caption (database or microflow source, either association spelling) CE0642 +// CaptionAttribute / optionsSourceAssociationCaptionAttribute ok +// CaptionType expression + CaptionExpression ok +// CaptionType expression + only CaptionAttribute CE0642 +// CaptionType expression, nothing else CE0642 +// CaptionExpression with no type, or with type attribute CE0642 +// enumeration mode (`Attribute: Kind`, no data source) ok +// +// Enumeration mode is not judged: it has no Caption property at all. +func validateComboBoxCaption(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + byExpression := strings.EqualFold(comboBoxPropText(w, "optionsSourceAssociationCaptionType"), "expression") + if byExpression { + if comboBoxPropText(w, "optionsSourceAssociationCaptionExpression") != "" { + return nil + } + } else if comboBoxPropText(w, "CaptionAttribute") != "" || + comboBoxPropText(w, "optionsSourceAssociationCaptionAttribute") != "" { return nil } + why := "has an options data source (association mode) but no caption" + fix := "Name the attribute shown for each option: `CaptionAttribute: `, " + + "or give an expression caption: `optionsSourceAssociationCaptionType: expression, " + + "optionsSourceAssociationCaptionExpression: '$currentObject/'`." + if !byExpression && comboBoxPropText(w, "optionsSourceAssociationCaptionExpression") != "" { + why = "has `optionsSourceAssociationCaptionExpression` but not `optionsSourceAssociationCaptionType: expression`, so the build reads the (absent) caption attribute" + fix = "Add `optionsSourceAssociationCaptionType: expression`, or replace the expression with `CaptionAttribute: `." + } + if byExpression { + why = "sets `optionsSourceAssociationCaptionType: expression` but no `optionsSourceAssociationCaptionExpression` — the expression type ignores `CaptionAttribute`" + fix = "Add `optionsSourceAssociationCaptionExpression: '$currentObject/'`, or drop the caption type and use `CaptionAttribute: `." + } return []linter.Violation{{ RuleID: "MDL-WIDGET16", Severity: linter.SeverityError, Message: fmt.Sprintf( - "%s: combobox `%s` binds an association (`Association:`) but has no `datasource:` — Mendix drops the binding and fails the build with CE0642 (\"Property 'Attribute' is required\")", - locationPrefix, w.Name), - Suggestion: "Association mode needs the option list and a caption: `combobox " + w.Name + " (Association: Module.Ref, datasource: database Module.TargetEntity, CaptionAttribute: Name)`.", + "%s: combobox `%s` %s — mxbuild fails the build with CE0642 (\"Property 'Caption' is required.\")", + locationPrefix, w.Name, why), + Suggestion: fix, }} } +// comboBoxPropText is a ComboBox property's value as text, "" when it is absent +// or empty. An expression value that is not a plain string counts as set. +func comboBoxPropText(w *ast.WidgetV3, key string) string { + v, ok := lookupProperty(w.Properties, key) + if !ok || v == nil { + return "" + } + if s, isString := v.(string); isString { + return strings.TrimSpace(s) + } + if s := strings.TrimSpace(stringifyAny(v)); s != "" { + return s + } + return "set" +} + // headingRenderModeRe matches the block-level dynamictext render modes H1–H6. var headingRenderModeRe = regexp.MustCompile(`(?i)^h[1-6]$`) diff --git a/mdl/executor/validate_widgets_test.go b/mdl/executor/validate_widgets_test.go index 13bf642cb0..6847340856 100644 --- a/mdl/executor/validate_widgets_test.go +++ b/mdl/executor/validate_widgets_test.go @@ -468,7 +468,8 @@ func TestValidateComboBoxAssociation(t *testing.T) { want bool // expect MDL-WIDGET16 }{ {"association, no datasource → flagged", &ast.WidgetV3{Type: "combobox", Name: "cb", Properties: map[string]any{"Association": "M.Order_Customer"}}, true}, - {"association + datasource → ok", &ast.WidgetV3{Type: "combobox", Name: "cb", Properties: map[string]any{"Association": "M.Order_Customer", "DataSource": dbDS}}, false}, + {"association + datasource + caption → ok", &ast.WidgetV3{Type: "combobox", Name: "cb", Properties: map[string]any{"Association": "M.Order_Customer", "DataSource": dbDS, "CaptionAttribute": "Name"}}, false}, + {"association + datasource, no caption → flagged (CE0642, #1466)", &ast.WidgetV3{Type: "combobox", Name: "cb", Properties: map[string]any{"Association": "M.Order_Customer", "DataSource": dbDS}}, true}, {"enumeration attribute only → ok", &ast.WidgetV3{Type: "combobox", Name: "cb", Properties: map[string]any{"Attribute": "Status"}}, false}, {"not a combobox → ignored", &ast.WidgetV3{Type: "textbox", Name: "tb", Properties: map[string]any{"Association": "M.Order_Customer"}}, false}, } From 0a639d3f8967881f3be24d6cf5c64a81648867e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:53:42 +0000 Subject: [PATCH 03/25] docs(skills): regenerate xpath-constraints' line index (mendixlabs/mxcli#1466) The skill grew with the comparand note; make check-skill-index refuses stale ranges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKmbj53BC2vuUWYmgWgfQ5 --- .claude/skills/mendix/xpath-constraints/SKILL.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/skills/mendix/xpath-constraints/SKILL.md b/.claude/skills/mendix/xpath-constraints/SKILL.md index c891bc7c9b..8280b09fd3 100644 --- a/.claude/skills/mendix/xpath-constraints/SKILL.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -13,12 +13,12 @@ description: "XPath constraint syntax for MDL — retrieve WHERE clauses, page d | 28-34 | When to Use This Skill | | 35-75 | XPath vs Mendix Expressions | | 76-119 | Syntax Reference | -| 120-278 | How a Constraint Is Laid Out on Disk | -| 279-329 | Usage Contexts | -| 330-369 | Enumeration Attributes | -| 370-411 | Common Patterns | -| 412-423 | Validation | -| 424-433 | Troubleshooting | +| 120-292 | How a Constraint Is Laid Out on Disk | +| 293-343 | Usage Contexts | +| 344-383 | Enumeration Attributes | +| 384-425 | Common Patterns | +| 426-437 | Validation | +| 438-447 | Troubleshooting | # XPath Constraints in MDL From 50ea77e0ddff3e13e6e6f96ef81840cfe4402cd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:32:42 +0000 Subject: [PATCH 04/25] fix(executor): carry business event channel identity on create or modify (mendixlabs/mxcli#1525) createBusinessEventService called generateChannelName() for every message on every build, including CREATE OR MODIFY of an existing service. Three runs of an unchanged script gave three different ChannelNames, each reported "Modified". The ChannelName is the Kafka topic a deployed app publishes on, so every re-run moved every topic and disconnected subscribers in other apps, with a clean mx check; and the rewrite could never be elided (ADR-0008). On a rewrite, carry the stored definition $ID and, per declared message matched by message name, the stored channel's $ID and ChannelName, the message $ID, attribute $IDs (by attribute name) and the operation $ID. A new message gets a fresh channel name. A stored channel holding several messages is handed to the first one only, so no two channels share a topic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...vent-channel-name-reminted-on-rewrite.json | 1 + .../skills/mendix/business-events/SKILL.md | 18 +- .../1525-business-event-channel-identity.mdl | 47 ++++ mdl/backend/modelsdk/identity_drift_test.go | 6 + mdl/executor/cmd_businessevents.go | 123 +++++++- .../cmd_businessevents_identity_test.go | 263 ++++++++++++++++++ 6 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-channel-name-reminted-on-rewrite.json create mode 100644 mdl-examples/bug-tests/1525-business-event-channel-identity.mdl create mode 100644 mdl/executor/cmd_businessevents_identity_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-channel-name-reminted-on-rewrite.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-channel-name-reminted-on-rewrite.json new file mode 100644 index 0000000000..dfdfe7cb9f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-channel-name-reminted-on-rewrite.json @@ -0,0 +1 @@ +{"date": "2026-10-16", "area": "mdl/executor", "symptom": "`create or modify business event service` of an existing service wrote a new random ChannelName for every message on every run: three runs of an unchanged script gave three different channel names, each reported `Modified business event service`. On a deployed app the ChannelName is the Kafka topic, so every re-run moves every topic and disconnects subscribers in other apps; mx check and the build stay clean.", "cause": "createBusinessEventService rebuilt the definition from the AST and called generateChannelName() for each message unconditionally, with no read of the stored channels. The canon $ID transplant cannot help: ChannelName is a content string, not an $ID, and no codec FreshGUIDField registration exists for it, so TestFreshGUIDFieldsHaveAnIdentityDecision never saw it (the same blind spot as Workflows PersistentId, #949).", "file": "mdl/executor/cmd_businessevents.go (businessEventIdentityCarry)", "fix": "On a rewrite, carry the stored definition $ID and, per declared message matched by message name (case-insensitive), the stored channel's $ID and ChannelName, the message $ID, attribute $IDs (by attribute name) and the service-operation $ID; a new message gets a fresh channel name. A stored channel holding several messages (Studio Pro can; MDL writes one channel per message) is handed to the first message only, so no two channels share a topic (PR #533).", "test": "mdl/executor/cmd_businessevents_identity_test.go: TestCreateOrModifyBusinessEventService_CarriesChannelIdentity (MockBackend, Studio Pro-shaped IDs, reordered messages/attributes plus a new message and attribute), _SharedStoredChannelNotDuplicated, _RerunIsUnchanged (PedApp copy: run 2 writes no file, reports Unchanged, same ChannelNames). Revert control (carry fed nil): all three fail, the rerun with `re-run moved the channels` and the .mxunit + .mpr rewritten.", "insight": "An identity need not look like one: ChannelName is a plain string property, but something outside the model (the broker, other apps' subscriptions) remembers it, which is the definition canon.identityFields uses. Grep the executor for generate*/GenerateID calls on a CREATE OR MODIFY path — each is a candidate. Carry by name, not position, so reordering a script cannot swap topics; the end-to-end rerun test (files unchanged + Unchanged report) is what proves every other nested ID is also carried.", "refs": ["mendixlabs/mxcli#1525", "ako/mxcli#1407", "docs-wiki/bug-patterns/rewrite-drops-unauthored-state.md"]} diff --git a/.claude/skills/mendix/business-events/SKILL.md b/.claude/skills/mendix/business-events/SKILL.md index 5d19a5ba4e..426775a98a 100644 --- a/.claude/skills/mendix/business-events/SKILL.md +++ b/.claude/skills/mendix/business-events/SKILL.md @@ -11,12 +11,12 @@ description: "Define event-driven APIs over Kafka with Mendix business event ser | lines | section | |---|---| | 24-31 | When to Use This Skill | -| 32-97 | Commands | -| 98-113 | Message Definition Syntax | -| 114-121 | Service Properties | -| 122-128 | Operations | -| 129-165 | Publishing Events from Microflows | -| 166-177 | Checklist | +| 32-101 | Commands | +| 102-117 | Message Definition Syntax | +| 118-125 | Service Properties | +| 126-132 | Operations | +| 133-169 | Publishing Events from Microflows | +| 170-181 | Checklist | # Business Events @@ -73,7 +73,11 @@ create business event service Module.CustomerEventsApi ### Create or Modify (Idempotent Update — Preferred) Preserves the existing UUID so other documents that reference this service remain valid. -`OR REPLACE` is accepted as a synonym. +`OR REPLACE` is accepted as a synonym. Each message keeps its stored channel — the +channel name is the Kafka topic subscribers listen on — matched by message name, so a +re-run of an unchanged script reports `Unchanged`; only a new message gets a new +(random) channel name. Renaming a message is a new channel. There is no MDL property to +state a channel name, so two apps defining "the same" service get different topics. ```sql mdl 1; diff --git a/mdl-examples/bug-tests/1525-business-event-channel-identity.mdl b/mdl-examples/bug-tests/1525-business-event-channel-identity.mdl new file mode 100644 index 0000000000..f4b2b19ac6 --- /dev/null +++ b/mdl-examples/bug-tests/1525-business-event-channel-identity.mdl @@ -0,0 +1,47 @@ +mdl 1; +-- ============================================================================ +-- Bug #1525: CREATE OR MODIFY business event service re-minted every ChannelName +-- ============================================================================ +-- +-- Symptom (before fix): +-- Every run of `create or modify business event service` generated a new +-- random ChannelName for every message, so a re-run of an unchanged script +-- reported "Modified" and moved the channels. The ChannelName is the Kafka +-- topic a deployed app publishes on; subscribers in other apps kept listening +-- on the old one. mx check and the build stay clean. +-- +-- After fix: +-- Each declared message keeps its stored channel (name and $ID), matched by +-- message name; only a new message gets a new channel name. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1525-business-event-channel-identity.mdl -p app.mpr +-- mxcli -p app.mpr -c "select ChannelName, MessageName from CATALOG.business_events" +-- mxcli exec mdl-examples/bug-tests/1525-business-event-channel-identity.mdl -p app.mpr +-- -> second run prints "Unchanged business event service: MyFirstModule.Bug1525Events" +-- and the catalog shows the same ChannelName per message. +-- +-- (Run `refresh catalog` between the two selects. A buildable publisher also +-- needs the BusinessEvents marketplace module and PBE_ entities that extend +-- BusinessEvents.PublishedBusinessEvent; the channel identity does not.) +-- ============================================================================ + +create or modify persistent entity MyFirstModule.PBE_Bug1525Order ( + OrderId: long, + Note: string(200) +); +create or modify persistent entity MyFirstModule.PBE_Bug1525Shipped ( + OrderId: long +); + +create or modify business event service MyFirstModule.Bug1525Events +( + ServiceName: 'Bug1525Events', + EventNamePrefix: '' +) +{ + message OrderCreated (OrderId: long, Note: string) publish + entity MyFirstModule.PBE_Bug1525Order; + message OrderShipped (OrderId: long) publish + entity MyFirstModule.PBE_Bug1525Shipped; +}; diff --git a/mdl/backend/modelsdk/identity_drift_test.go b/mdl/backend/modelsdk/identity_drift_test.go index 8c34526021..d8174c175c 100644 --- a/mdl/backend/modelsdk/identity_drift_test.go +++ b/mdl/backend/modelsdk/identity_drift_test.go @@ -54,6 +54,12 @@ var churnIsIntended = map[string][]string{} // decision — but "registered with the codec" is narrower than "minted fresh". // When adding a writer that mints an identity by hand, either register it so // this guard can see it, or carry it in canon and say so here. +// +// BusinessEvents$Channel.ChannelName is the same blind spot (mendixlabs/mxcli#1525): +// a random hex string minted by the executor on every build, and the Kafka topic +// subscribers in other apps listen on. It is carried by message name in the +// executor (businessEventIdentityCarry), which is the layer that knows which +// stored channel a declared message is. func TestFreshGUIDFieldsHaveAnIdentityDecision(t *testing.T) { registered := codec.FreshGUIDRegistrations() diff --git a/mdl/executor/cmd_businessevents.go b/mdl/executor/cmd_businessevents.go index 4285057d7f..267cdf2d3d 100644 --- a/mdl/executor/cmd_businessevents.go +++ b/mdl/executor/cmd_businessevents.go @@ -281,6 +281,7 @@ func createBusinessEventService(ctx *ExecContext, stmt *ast.CreateBusinessEventS // about folders (#932). var existingContainerID model.ID var existingDocumentation string + var existingSvc *model.BusinessEventService haveExistingSvc := false if existing, ok := pickLive(existingServices, func(svc *model.BusinessEventService) bool { @@ -296,6 +297,7 @@ func createBusinessEventService(ctx *ExecContext, stmt *ast.CreateBusinessEventS existingExcluded = existing.Excluded existingContainerID = existing.ContainerID existingDocumentation = existing.Documentation + existingSvc = existing haveExistingSvc = true } @@ -325,24 +327,28 @@ func createBusinessEventService(ctx *ExecContext, stmt *ast.CreateBusinessEventS svc.ID = existingID } - // Build definition + // Build definition. On a rewrite every identity the stored service holds is + // carried by name (mendixlabs/mxcli#1525): the ChannelName is the topic + // subscribers listen on, so minting a new one per run moved every topic of a + // deployed app and made an unchanged re-run a write. + carry := newBusinessEventIdentityCarry(existingSvc) def := &model.BusinessEventDefinition{ ServiceName: stmt.ServiceName, EventNamePrefix: stmt.EventNamePrefix, } + def.ID = carry.definitionID() def.TypeName = "BusinessEvents$BusinessEventDefinition" // Create channels (one per message in our simplified model) for _, msgDef := range stmt.Messages { - ch := &model.BusinessEventChannel{ - ChannelName: generateChannelName(), - } + ch := &model.BusinessEventChannel{} ch.TypeName = "BusinessEvents$Channel" msg := &model.BusinessEventMessage{ MessageName: msgDef.MessageName, } msg.TypeName = "BusinessEvents$Message" + storedMsg := carry.channelFor(msgDef.MessageName, ch, msg) // Set publish/subscribe based on operation switch strings.ToLower(msgDef.Operation) { @@ -358,6 +364,7 @@ func createBusinessEventService(ctx *ExecContext, stmt *ast.CreateBusinessEventS AttributeName: attrDef.Name, AttributeType: attrDef.TypeName, } + attr.ID = storedAttributeID(storedMsg, attrDef.Name) attr.TypeName = "BusinessEvents$MessageAttribute" msg.Attributes = append(msg.Attributes, attr) } @@ -372,6 +379,7 @@ func createBusinessEventService(ctx *ExecContext, stmt *ast.CreateBusinessEventS Entity: msgDef.Entity, Microflow: msgDef.Microflow, } + op.ID = carry.operationID(msgDef.MessageName) op.TypeName = "BusinessEvents$ServiceOperation" svc.OperationImplementations = append(svc.OperationImplementations, op) } @@ -430,6 +438,113 @@ func dropBusinessEventService(ctx *ExecContext, stmt *ast.DropBusinessEventServi return mdlerrors.NewNotFound("business event service", stmt.Name.String()) } +// businessEventIdentityCarry hands the identities of a stored business event +// service to the rewrite that replaces it, keyed by message name (and attribute +// name), never by position: a reordered script must not swap two topics. +// +// The ChannelName is what makes this more than $ID hygiene. It is the channel's +// identity outside the model — the topic name publishers and subscribers in +// other apps agree on — so a rewrite that re-mints it silently disconnects every +// subscriber on the next deploy, with a perfectly valid model. Element $IDs are +// carried alongside so an unchanged rewrite equals the stored unit and is elided +// (ADR-0008). +type businessEventIdentityCarry struct { + stored *model.BusinessEventService + messages map[string]storedBusinessEventMessage + claimed map[*model.BusinessEventChannel]bool + ops map[string]*model.ServiceOperation +} + +type storedBusinessEventMessage struct { + channel *model.BusinessEventChannel + message *model.BusinessEventMessage +} + +func newBusinessEventIdentityCarry(stored *model.BusinessEventService) *businessEventIdentityCarry { + c := &businessEventIdentityCarry{ + stored: stored, + messages: map[string]storedBusinessEventMessage{}, + claimed: map[*model.BusinessEventChannel]bool{}, + ops: map[string]*model.ServiceOperation{}, + } + if stored == nil { + return c + } + if stored.Definition != nil { + for _, ch := range stored.Definition.Channels { + for _, m := range ch.Messages { + key := strings.ToLower(m.MessageName) + if _, dup := c.messages[key]; !dup { + c.messages[key] = storedBusinessEventMessage{channel: ch, message: m} + } + } + } + } + for _, op := range stored.OperationImplementations { + key := strings.ToLower(op.MessageName) + if _, dup := c.ops[key]; !dup { + c.ops[key] = op + } + } + return c +} + +func (c *businessEventIdentityCarry) definitionID() model.ID { + if c.stored == nil || c.stored.Definition == nil { + return "" + } + return c.stored.Definition.ID +} + +// channelFor fills ch and msg with the stored identities of the message named +// name and returns the stored message (nil for a new one). A new message gets a +// freshly generated ChannelName. A stored channel is handed out once: Studio Pro +// can put several messages in one channel, MDL writes one channel per message, +// and two channels sharing a ChannelName is a duplicate topic (PR #533). +func (c *businessEventIdentityCarry) channelFor(name string, ch *model.BusinessEventChannel, msg *model.BusinessEventMessage) *model.BusinessEventMessage { + key := strings.ToLower(name) + s, ok := c.messages[key] + if !ok { + ch.ChannelName = generateChannelName() + return nil + } + delete(c.messages, key) + msg.ID = s.message.ID + if c.claimed[s.channel] || s.channel.ChannelName == "" { + ch.ChannelName = generateChannelName() + } else { + c.claimed[s.channel] = true + ch.ID = s.channel.ID + ch.ChannelName = s.channel.ChannelName + } + return s.message +} + +// operationID is the $ID of the stored operation implementation for the named +// message, handed out once, or "". +func (c *businessEventIdentityCarry) operationID(messageName string) model.ID { + key := strings.ToLower(messageName) + op, ok := c.ops[key] + if !ok { + return "" + } + delete(c.ops, key) + return op.ID +} + +// storedAttributeID is the $ID of the stored attribute named name, or "". +func storedAttributeID(stored *model.BusinessEventMessage, name string) model.ID { + if stored == nil { + return "" + } + for _, a := range stored.Attributes { + if strings.EqualFold(a.AttributeName, name) { + return a.ID + } + } + return "" +} + // generateChannelName generates a hex channel name (similar to Mendix Studio Pro). func generateChannelName() string { // Generate a UUID-like hex string diff --git a/mdl/executor/cmd_businessevents_identity_test.go b/mdl/executor/cmd_businessevents_identity_test.go new file mode 100644 index 0000000000..1db798662f --- /dev/null +++ b/mdl/executor/cmd_businessevents_identity_test.go @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1525: `create or modify business event service` minted a new +// random ChannelName for every message on every run. The ChannelName is the +// channel's identity outside the model — the topic subscribers listen on — so a +// re-run of an unchanged script moved every topic of a deployed app, and could +// never report Unchanged (ADR-0008). A rewrite must carry the stored channel +// (its ChannelName and $ID) and the stored message/attribute/operation $IDs, +// matched by message name and attribute name; only a new message gets a new +// channel. +func TestCreateOrModifyBusinessEventService_CarriesChannelIdentity(t *testing.T) { + mod := mkModule("M") + // Studio Pro-shaped identities: none of them equal to anything the executor + // would mint, so a re-mint cannot pass by coincidence. + stored := &model.BusinessEventService{ + BaseElement: model.BaseElement{ID: nextID("bes")}, + ContainerID: mod.ID, + Name: "OrderEvents", + ExportLevel: "Hidden", + Definition: &model.BusinessEventDefinition{ + BaseElement: model.BaseElement{ID: "sp-def"}, + ServiceName: "OrderEvents", + Channels: []*model.BusinessEventChannel{ + { + BaseElement: model.BaseElement{ID: "sp-ch-created"}, + ChannelName: "1111aaaa2222bbbb3333cccc4444dddd", + Messages: []*model.BusinessEventMessage{{ + BaseElement: model.BaseElement{ID: "sp-msg-created"}, + MessageName: "OrderCreated", + Attributes: []*model.BusinessEventAttribute{ + {BaseElement: model.BaseElement{ID: "sp-attr-orderid"}, AttributeName: "OrderId", AttributeType: "Long"}, + {BaseElement: model.BaseElement{ID: "sp-attr-note"}, AttributeName: "Note", AttributeType: "String"}, + }, + }}, + }, + { + BaseElement: model.BaseElement{ID: "sp-ch-shipped"}, + ChannelName: "5555eeee6666ffff77770000888899990", + Messages: []*model.BusinessEventMessage{{ + BaseElement: model.BaseElement{ID: "sp-msg-shipped"}, + MessageName: "OrderShipped", + }}, + }, + }, + }, + OperationImplementations: []*model.ServiceOperation{ + {BaseElement: model.BaseElement{ID: "sp-op-created"}, MessageName: "OrderCreated", Operation: "publish", Entity: "M.PBE_Order"}, + {BaseElement: model.BaseElement{ID: "sp-op-shipped"}, MessageName: "OrderShipped", Operation: "publish", Entity: "M.PBE_Order"}, + }, + } + h := mkHierarchy(mod) + withContainer(h, stored.ContainerID, mod.ID) + + var written *model.BusinessEventService + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListBusinessEventServicesFunc: func() ([]*model.BusinessEventService, error) { + return []*model.BusinessEventService{stored}, nil + }, + UpdateBusinessEventServiceFunc: func(svc *model.BusinessEventService) error { + written = svc + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + + // Reordered messages, reordered attributes, one new attribute, one new message. + err := createBusinessEventService(ctx, &ast.CreateBusinessEventServiceStmt{ + Name: ast.QualifiedName{Module: "M", Name: "OrderEvents"}, + ServiceName: "OrderEvents", + CreateOrModify: true, + Messages: []*ast.BusinessEventMessageDef{ + {MessageName: "OrderShipped", Operation: "publish", Entity: "M.PBE_Order"}, + {MessageName: "OrderCreated", Operation: "publish", Entity: "M.PBE_Order", Attributes: []*ast.BusinessEventAttributeDef{ + {Name: "Note", TypeName: "String"}, + {Name: "OrderId", TypeName: "Long"}, + {Name: "Extra", TypeName: "String"}, + }}, + {MessageName: "OrderCancelled", Operation: "publish", Entity: "M.PBE_Order"}, + }, + }) + assertNoError(t, err) + if written == nil || written.Definition == nil { + t.Fatal("UpdateBusinessEventService was not called with a definition") + } + if written.Definition.ID != "sp-def" { + t.Errorf("definition $ID = %q, want the stored sp-def", written.Definition.ID) + } + + byMsg := map[string]*model.BusinessEventChannel{} + for _, ch := range written.Definition.Channels { + if len(ch.Messages) != 1 { + t.Fatalf("channel %q holds %d messages, want 1", ch.ChannelName, len(ch.Messages)) + } + byMsg[ch.Messages[0].MessageName] = ch + } + + created, shipped, cancelled := byMsg["OrderCreated"], byMsg["OrderShipped"], byMsg["OrderCancelled"] + if created == nil || shipped == nil || cancelled == nil { + t.Fatalf("missing channels: %+v", byMsg) + } + if created.ChannelName != "1111aaaa2222bbbb3333cccc4444dddd" || created.ID != "sp-ch-created" { + t.Errorf("OrderCreated channel = (%q, %q), want the stored (1111aaaa..., sp-ch-created)", created.ChannelName, created.ID) + } + if shipped.ChannelName != "5555eeee6666ffff77770000888899990" || shipped.ID != "sp-ch-shipped" { + t.Errorf("OrderShipped channel = (%q, %q), want the stored (5555eeee..., sp-ch-shipped)", shipped.ChannelName, shipped.ID) + } + if created.Messages[0].ID != "sp-msg-created" || shipped.Messages[0].ID != "sp-msg-shipped" { + t.Errorf("message $IDs not carried: created=%q shipped=%q", created.Messages[0].ID, shipped.Messages[0].ID) + } + attrIDs := map[string]model.ID{} + for _, a := range created.Messages[0].Attributes { + attrIDs[a.AttributeName] = a.ID + } + if attrIDs["OrderId"] != "sp-attr-orderid" || attrIDs["Note"] != "sp-attr-note" { + t.Errorf("attribute $IDs not carried by name: %v", attrIDs) + } + if attrIDs["Extra"] != "" { + t.Errorf("a new attribute must not take a stored $ID, got %q", attrIDs["Extra"]) + } + + // A new message gets a channel of its own, never a stored one. + if cancelled.ChannelName == "" || cancelled.ID != "" || cancelled.Messages[0].ID != "" { + t.Errorf("new message's channel = (%q, %q, msg %q), want a fresh name and no stored $IDs", + cancelled.ChannelName, cancelled.ID, cancelled.Messages[0].ID) + } + if cancelled.ChannelName == created.ChannelName || cancelled.ChannelName == shipped.ChannelName { + t.Errorf("new message reused a stored ChannelName %q", cancelled.ChannelName) + } + + opIDs := map[string]model.ID{} + for _, op := range written.OperationImplementations { + opIDs[op.MessageName] = op.ID + } + if opIDs["OrderCreated"] != "sp-op-created" || opIDs["OrderShipped"] != "sp-op-shipped" || opIDs["OrderCancelled"] != "" { + t.Errorf("operation $IDs not carried by message name: %v", opIDs) + } +} + +// A stored channel holding two messages (expressible in Studio Pro, not in MDL, +// which writes one channel per message) must not be handed to both: two channels +// sharing a ChannelName is the duplicate PR #533 crashed on. The first message +// keeps the stored channel; the second gets a fresh one. +func TestCreateOrModifyBusinessEventService_SharedStoredChannelNotDuplicated(t *testing.T) { + mod := mkModule("M") + stored := &model.BusinessEventService{ + BaseElement: model.BaseElement{ID: nextID("bes")}, + ContainerID: mod.ID, + Name: "Evts", + Definition: &model.BusinessEventDefinition{ + Channels: []*model.BusinessEventChannel{{ + BaseElement: model.BaseElement{ID: "sp-ch"}, + ChannelName: "abcabcabcabcabcabcabcabcabcabcab", + Messages: []*model.BusinessEventMessage{ + {BaseElement: model.BaseElement{ID: "sp-a"}, MessageName: "A"}, + {BaseElement: model.BaseElement{ID: "sp-b"}, MessageName: "B"}, + }, + }}, + }, + } + h := mkHierarchy(mod) + withContainer(h, stored.ContainerID, mod.ID) + var written *model.BusinessEventService + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListBusinessEventServicesFunc: func() ([]*model.BusinessEventService, error) { + return []*model.BusinessEventService{stored}, nil + }, + UpdateBusinessEventServiceFunc: func(svc *model.BusinessEventService) error { written = svc; return nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, createBusinessEventService(ctx, &ast.CreateBusinessEventServiceStmt{ + Name: ast.QualifiedName{Module: "M", Name: "Evts"}, + CreateOrModify: true, + Messages: []*ast.BusinessEventMessageDef{ + {MessageName: "A", Operation: "publish"}, + {MessageName: "B", Operation: "publish"}, + }, + })) + chs := written.Definition.Channels + if len(chs) != 2 { + t.Fatalf("got %d channels, want 2", len(chs)) + } + if chs[0].ChannelName != "abcabcabcabcabcabcabcabcabcabcab" || chs[0].ID != "sp-ch" { + t.Errorf("first message lost the stored channel: (%q, %q)", chs[0].ChannelName, chs[0].ID) + } + if chs[1].ChannelName == chs[0].ChannelName || chs[1].ID == chs[0].ID { + t.Errorf("second message duplicated the stored channel: (%q, %q)", chs[1].ChannelName, chs[1].ID) + } + if chs[1].Messages[0].ID != "sp-b" { + t.Errorf("message B's $ID = %q, want the stored sp-b", chs[1].Messages[0].ID) + } +} + +// The same defect measured end to end on a real project: the second run of an +// unchanged script must write nothing, report Unchanged and keep every +// ChannelName. Run 1 is the control — it is a write and says so. +func TestCreateOrModifyBusinessEventService_RerunIsUnchanged(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + const svc = `create or modify business event service MyFirstModule.Rerun1525 (ServiceName: 'Rerun1525', EventNamePrefix: '') +{ + message OrderCreated (OrderId: long, Note: string) publish entity MyFirstModule.PBE_Rerun1525; + message OrderShipped (OrderId: long) publish entity MyFirstModule.PBE_Rerun1525; +};` + if err := afRun(t, exec, `create persistent entity MyFirstModule.PBE_Rerun1525 (OrderId: Long, Note: String(200));`+"\n"+svc); err != nil { + t.Fatalf("run 1: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "Created business event service") { + t.Fatalf("run 1 is a create and must say so:\n%s", out.String()) + } + names := func() map[string]string { + t.Helper() + all, err := exec.newExecContext(context.Background()).Backend.ListBusinessEventServices() + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, s := range all { + if s.Name != "Rerun1525" || s.Definition == nil { + continue + } + for _, ch := range s.Definition.Channels { + for _, m := range ch.Messages { + got[m.MessageName] = ch.ChannelName + } + } + } + return got + } + first := names() + if len(first) != 2 { + t.Fatalf("channels after run 1: %v", first) + } + + before := projectFiles(t, dir) + out.Reset() + if err := afRun(t, exec, svc); err != nil { + t.Fatalf("run 2: %v\n%s", err, out.String()) + } + if second := names(); second["OrderCreated"] != first["OrderCreated"] || second["OrderShipped"] != first["OrderShipped"] { + t.Errorf("re-run moved the channels: before %v, after %v", first, second) + } + if changed := diffProjectFiles(before, projectFiles(t, dir)); len(changed) != 0 { + t.Errorf("re-run of an unchanged script wrote %v", changed) + } + if got := out.String(); !strings.Contains(got, "Unchanged business event service") { + t.Errorf("re-run wrote nothing and must report Unchanged, got:\n%s", got) + } +} From 81bd757fe43b6e13f4a68befe3f5475fad348c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:42:48 +0000 Subject: [PATCH 05/25] fix(backend): describe workflow keeps the call workflow parameter mapping (mendixlabs/mxcli#1531) The gen->sdk workflow reader converted a CallWorkflowActivity's Workflow, ParameterExpression and boundary events but never its ParameterMappings, so describe printed `call workflow M.WF_Child;` for a call stored with `(WorkflowContext = $WorkflowContext/M.Parent_Current/M.Item)`. Re-executing that output let autoBindCallWorkflow write the default `$WorkflowContext`, silently passing the parent's context object instead (CE0117 on 11.15.0). Add workflowParamMappingsFromGen, the call-workflow twin of microflowParamMappingsFromGen, and wire it into the CallWorkflowActivity case. A writer->gen->reader unit test covers the reader layer the describe tests skip, and an integration test proves describe -> exec reports "Unchanged workflow" with the mapping intact. Measured on 11.15.0: the pre-fix round trip goes 0 -> 1 error (CE0117); the fixed one stays at 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...drops-call-workflow-parameter-mapping.json | 1 + ...ll-workflow-mapping-describe-roundtrip.mdl | 39 ++++++++++++ .../workflow_callworkflow_read_test.go | 60 +++++++++++++++++++ mdl/backend/modelsdk/workflow_read.go | 23 +++++++ .../call_workflow_mapping_1531_test.go | 51 ++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-16-describe-drops-call-workflow-parameter-mapping.json create mode 100644 mdl-examples/bug-tests/1531-call-workflow-mapping-describe-roundtrip.mdl create mode 100644 mdl/backend/modelsdk/workflow_callworkflow_read_test.go create mode 100644 mdl/roundtrip/call_workflow_mapping_1531_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-describe-drops-call-workflow-parameter-mapping.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-describe-drops-call-workflow-parameter-mapping.json new file mode 100644 index 0000000000..8cb5fc56e5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-describe-drops-call-workflow-parameter-mapping.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-16", "symptom": "`describe workflow M.WF_Parent` printed `call workflow M.WF_Child;` for a call stored with `(WorkflowContext = $WorkflowContext/M.Parent_Current/M.Item)`; re-executing the describe output reported \"Modified workflow\" and mx check went from 0 errors to CE0117 (mendixlabs/mxcli#1531).", "cause": "The gen->sdk reader's CallWorkflowActivity case copied Workflow, ParameterExpression and BoundaryEvents but never ParameterMappingsItems(); describe's workflowCallArguments prints mappings when populated, so it had nothing to print. On re-exec, autoBindCallWorkflow saw no mapping and wrote the default `$WorkflowContext` - the parent's context object, not the associated child object.", "file": "mdl/backend/modelsdk/workflow_read.go (workflowParamMappingsFromGen, twin of microflowParamMappingsFromGen, wired into the CallWorkflowActivity case)", "insight": "The call-microflow cases had the identical gap fixed earlier (FINDINGS #42) and the call-workflow case right below them was not touched - when one arm of a type switch gains a property read, diff it against its siblings. The describe tests build semantic structs directly, so the reader layer had no coverage; the test that catches this goes writer -> gen -> reader (roundTripWorkflowActivity). The silent part is the auto-bind default, not the drop: a missing mapping is re-filled with a plausible one. Measured on 11.15.0: pre-fix round trip CE0117, fixed round trip prints \"Unchanged workflow\" and 0 errors. Side measurement: `$WorkflowContext/M.Parent_Current` (no target entity) is CE0117 on 11.15.0 as on 11.6.6 - not a version difference, the path must name the target entity.", "refs": ["mdl/backend/modelsdk/workflow_callworkflow_read_test.go", "mdl-examples/bug-tests/1531-call-workflow-mapping-describe-roundtrip.mdl"]} diff --git a/mdl-examples/bug-tests/1531-call-workflow-mapping-describe-roundtrip.mdl b/mdl-examples/bug-tests/1531-call-workflow-mapping-describe-roundtrip.mdl new file mode 100644 index 0000000000..37f1462969 --- /dev/null +++ b/mdl-examples/bug-tests/1531-call-workflow-mapping-describe-roundtrip.mdl @@ -0,0 +1,39 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1531 - describe workflow drops the call +-- workflow parameter mapping +-- ============================================================================ +-- +-- Symptom: +-- `describe workflow M.WF_Parent` printed `call workflow M.WF_Child;` with no +-- mapping. Re-executing that output let the executor bind the default +-- `$WorkflowContext` (an M.Parent) to a child that takes an M.Item, so the +-- association path was silently replaced and mx check reported CE0117. +-- +-- After fix: +-- describe prints `call workflow M.WF_Child(WorkflowContext = +-- $WorkflowContext/M.Parent_Current/M.Item)`; re-executing it reports +-- "Unchanged workflow: M.WF_Parent" and mx check stays at 0 errors. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1531-call-workflow-mapping-describe-roundtrip.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe workflow M.WF_Parent" -- shows the mapping +-- ============================================================================ + +create module M; +create persistent entity M.Item (Name: string(100)); +create persistent entity M.Parent (Name: string(100)); +create association M.Parent_Current from M.Parent to M.Item; + +create workflow M.WF_Child + parameter $WorkflowContext: M.Item +begin +end workflow; + +-- The path names the target entity; `$WorkflowContext/M.Parent_Current` +-- without it is CE0117 (measured on 11.6.6 and 11.15.0). +create workflow M.WF_Parent + parameter $WorkflowContext: M.Parent +begin + call workflow M.WF_Child(WorkflowContext = $WorkflowContext/M.Parent_Current/M.Item); +end workflow; diff --git a/mdl/backend/modelsdk/workflow_callworkflow_read_test.go b/mdl/backend/modelsdk/workflow_callworkflow_read_test.go new file mode 100644 index 0000000000..73ee1af2c8 --- /dev/null +++ b/mdl/backend/modelsdk/workflow_callworkflow_read_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// mendixlabs/mxcli#1531. The reader converted a CallWorkflowActivity's Workflow, +// ParameterExpression and boundary events but never its ParameterMappings, so +// DESCRIBE printed `call workflow M.WF_Child;` for a call that maps an +// association path. Re-executing that output let autoBindCallWorkflow write the +// default `$WorkflowContext` mapping — silently passing a different object. +// The describe tests feed semantic structs directly, so only a test through the +// writer and the reader covers this layer. +func TestWorkflowRead_CallWorkflowParameterMappingsRoundTrip(t *testing.T) { + call := &workflows.CallWorkflowActivity{ + Workflow: "M.WF_Child", + ParameterExpression: "$WorkflowContext", + } + call.Name = "WF_Child" + call.ParameterMappings = []*workflows.ParameterMapping{{ + Parameter: "M.WF_Child.WorkflowContext", + Expression: "$WorkflowContext/M.Parent_Current/M.Item", + }} + call.ParameterMappings[0].ID = "11111111-2222-3333-4444-555555555555" + + rt, ok := roundTripWorkflowActivity(t, call).(*workflows.CallWorkflowActivity) + if !ok { + t.Fatalf("round-tripped to %T, want *workflows.CallWorkflowActivity", rt) + } + if len(rt.ParameterMappings) != 1 { + t.Fatalf("parameter mappings after round trip = %d, want 1 (the reader dropped it)", len(rt.ParameterMappings)) + } + pm := rt.ParameterMappings[0] + if pm.Parameter != "M.WF_Child.WorkflowContext" { + t.Errorf("Parameter = %q, want M.WF_Child.WorkflowContext", pm.Parameter) + } + if pm.Expression != "$WorkflowContext/M.Parent_Current/M.Item" { + t.Errorf("Expression = %q, want the association path", pm.Expression) + } + if pm.ID != call.ParameterMappings[0].ID { + t.Errorf("mapping ID = %q, want the stored %q", pm.ID, call.ParameterMappings[0].ID) + } +} + +// Control: a call with no mapping must not gain one on read. +func TestWorkflowRead_CallWorkflowWithoutMappingsStaysEmpty(t *testing.T) { + call := &workflows.CallWorkflowActivity{Workflow: "M.WF_Child"} + call.Name = "WF_Child" + rt, ok := roundTripWorkflowActivity(t, call).(*workflows.CallWorkflowActivity) + if !ok { + t.Fatalf("round-tripped to %T, want *workflows.CallWorkflowActivity", rt) + } + if len(rt.ParameterMappings) != 0 { + t.Errorf("parameter mappings = %d, want 0", len(rt.ParameterMappings)) + } +} diff --git a/mdl/backend/modelsdk/workflow_read.go b/mdl/backend/modelsdk/workflow_read.go index 44b995c5e1..651ec87494 100644 --- a/mdl/backend/modelsdk/workflow_read.go +++ b/mdl/backend/modelsdk/workflow_read.go @@ -245,6 +245,7 @@ func workflowActivityFromGen(el element.Element) workflows.WorkflowActivity { } setWfBase(&t.BaseWorkflowActivity, a.ID(), a.Name(), a.Caption(), a.Annotation(), "Workflows$CallWorkflowActivity") t.BoundaryEvents = boundaryEventsFromGen(a.BoundaryEventsItems()) + t.ParameterMappings = workflowParamMappingsFromGen(a.ParameterMappingsItems()) return t case *genWf.ExclusiveSplitActivity: t := &workflows.ExclusiveSplitActivity{Expression: a.Expression()} @@ -404,6 +405,28 @@ func microflowParamMappingsFromGen(items []element.Element) []*workflows.Paramet return out } +// workflowParamMappingsFromGen is the call-workflow twin of +// microflowParamMappingsFromGen. Without it DESCRIBE printed a bare +// `call workflow M.WF_Child;` for a call that maps an association path, and +// re-executing that output let autoBindCallWorkflow write the default +// `$WorkflowContext` mapping in its place (mendixlabs/mxcli#1531). +func workflowParamMappingsFromGen(items []element.Element) []*workflows.ParameterMapping { + var out []*workflows.ParameterMapping + for _, el := range items { + pm, ok := el.(*genWf.WorkflowCallParameterMapping) + if !ok { + continue + } + m := &workflows.ParameterMapping{ + Parameter: pm.ParameterQualifiedName(), + Expression: pm.Expression(), + } + m.ID = model.ID(pm.ID()) + out = append(out, m) + } + return out +} + func conditionOutcomesFromGen(items []element.Element) []workflows.ConditionOutcome { var out []workflows.ConditionOutcome for _, el := range items { diff --git a/mdl/roundtrip/call_workflow_mapping_1531_test.go b/mdl/roundtrip/call_workflow_mapping_1531_test.go new file mode 100644 index 0000000000..0e501a6b53 --- /dev/null +++ b/mdl/roundtrip/call_workflow_mapping_1531_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "strings" + "testing" +) + +// mendixlabs/mxcli#1531: describe printed `call workflow M.WF_Child;` for a call +// stored with an association-path mapping. Re-executing it let the executor +// bind the default `$WorkflowContext` — the parent's context object — so the +// describe -> exec loop silently replaced the mapping (CE0117 on 11.15.0). +func TestCallWorkflowMappingSurvivesDescribeExec(t *testing.T) { + h := newHarness(t) + defer h.close() + + const mapping = "(WorkflowContext = $WorkflowContext/CWF1531.Parent_Current/CWF1531.Item)" + if err := h.exec(`create module CWF1531; +create persistent entity CWF1531.Item (Name: string(100)); +create persistent entity CWF1531.Parent (Name: string(100)); +create association CWF1531.Parent_Current from CWF1531.Parent to CWF1531.Item; +create workflow CWF1531.WF_Child + parameter $WorkflowContext: CWF1531.Item +begin +end workflow; +create workflow CWF1531.WF_Parent + parameter $WorkflowContext: CWF1531.Parent +begin + call workflow CWF1531.WF_Child` + mapping + `; +end workflow;`); err != nil { + t.Fatalf("setup: %v", err) + } + + first := h.mustDescribeMdl0(t, "workflow CWF1531.WF_Parent") + if !strings.Contains(first, "call workflow CWF1531.WF_Child"+mapping) { + t.Fatalf("describe dropped the parameter mapping:\n%s", first) + } + + if err := h.exec(first); err != nil { + t.Fatalf("re-exec describe output: %v", err) + } + if out := h.out.String(); !strings.Contains(out, "Unchanged workflow") { + t.Errorf("re-executing the describe output wrote the workflow:\n%s", out) + } + if second := h.mustDescribeMdl0(t, "workflow CWF1531.WF_Parent"); second != first { + t.Errorf("describe changed after describe -> exec:\n--- first ---\n%s\n--- second ---\n%s", first, second) + } +} From 9c0b2d56e1c2a178bac36848b56a741cf3885af8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:46:55 +0000 Subject: [PATCH 06/25] fix(executor): check business event message attributes against inherited members (mendixlabs/mxcli#1525) A message attribute named like a member the event entity inherits from BusinessEvents.PublishedBusinessEvent (the reporter's EventId) passed check and exec; mxbuild then reported "The Business Events attribute 'EventId' is not present on the entity." (measured on 11.6.6 and 11.15.0 with a stub base entity; the control without the attribute is clean of it). An inherited attribute does not count and cannot be redeclared, so such a message can never be satisfied. The --references pass now walks the message entity's generalization chain, script-declared entities first and then the project, and reports a message attribute an ancestor declares. The inherited members are read from the model, not a list in mxcli; where the chain cannot be read (no BusinessEvents module) nothing is reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...vent-message-attribute-inherited-name.json | 1 + .../skills/mendix/business-events/SKILL.md | 3 +- mdl/executor/validate.go | 4 + .../validate_business_event_members.go | 129 +++++++++++++++ .../validate_business_event_members_test.go | 155 ++++++++++++++++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-message-attribute-inherited-name.json create mode 100644 mdl/executor/validate_business_event_members.go create mode 100644 mdl/executor/validate_business_event_members_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-message-attribute-inherited-name.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-message-attribute-inherited-name.json new file mode 100644 index 0000000000..f865b4ac1b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-business-event-message-attribute-inherited-name.json @@ -0,0 +1 @@ +{"date": "2026-10-16", "area": "mdl/executor", "symptom": "A business event message attribute named like a member the event entity inherits from BusinessEvents.PublishedBusinessEvent (`message Msg (EventId: long, ...)` on a PBE_ entity extending it) passed check and exec; mxbuild then reported \"The Business Events attribute 'EventId' is not present on the entity.\" on the service (measured mx 11.6.6 and 11.15.0, blank apps with a stub PublishedBusinessEvent declaring EventId: 2 errors -> 1 with the attribute dropped, the remaining one being the stub's missing module version folder).", "cause": "No check compared message attributes with the entity's generalization chain. The entity must declare one attribute per message attribute, an inherited one does not count, and it cannot redeclare an inherited name, so the message can never be satisfied.", "file": "mdl/executor/validate_business_event_members.go (validateBusinessEventInheritedMembers, wired into validateProgramWithWarnings)", "fix": "In the --references pass, walk the message entity's generalization chain — script-declared entities first, then the project — and report a message attribute whose name (case-insensitive) an ancestor declares, naming the declaring entity. The inherited members are read from the model, not from a list in mxcli; where the chain cannot be read (no BusinessEvents module, unresolvable ancestor) nothing is reported.", "test": "mdl/executor/validate_business_event_members_test.go (project entity, script entity with case-different name, three silent controls, wiring through validateProgramWithWarnings). Revert control (inherited set forced empty): the three positive tests fail.", "insight": "mxbuild's wording ('not present on the entity') points at the entity, while the fix is to rename the MESSAGE attribute — say so in the diagnostic. The real marketplace PublishedBusinessEvent's member list was not available here; reading it from the project is what keeps the check right across module versions.", "refs": ["mendixlabs/mxcli#1525", "ako/mxcli#1407", "mendixlabs/mxcli#476"]} diff --git a/.claude/skills/mendix/business-events/SKILL.md b/.claude/skills/mendix/business-events/SKILL.md index 426775a98a..f0527f64dd 100644 --- a/.claude/skills/mendix/business-events/SKILL.md +++ b/.claude/skills/mendix/business-events/SKILL.md @@ -16,7 +16,7 @@ description: "Define event-driven APIs over Kafka with Mendix business event ser | 118-125 | Service Properties | | 126-132 | Operations | | 133-169 | Publishing Events from Microflows | -| 170-181 | Checklist | +| 170-182 | Checklist | # Business Events @@ -172,6 +172,7 @@ end; - [ ] Linked entities must exist before creating the service - [ ] **Entities must extend `BusinessEvents.PublishedBusinessEvent`** (for published events) - [ ] **Entity attributes must exactly match the message attributes** (no extra/missing attributes) +- [ ] **No message attribute may reuse a name the entity inherits** from `BusinessEvents.PublishedBusinessEvent` (e.g. `EventId`) — an inherited attribute does not count, so mxbuild reports it "not present on the entity". `check -p … --references` reports it - [ ] Entity names use qualified format: `Module.EntityName` - [ ] Entities for published events are conventionally prefixed with `PBE_` - [ ] Attribute types must be valid (String, Integer, Long, Decimal, Boolean, DateTime) diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 5f98e2162b..6ca2c954a2 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -493,6 +493,10 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // Usable microflow there exposing a Hidden entity: mxbuild's CE1406 and // CE5400, after exec has written the model (mendixlabs/mxcli#1498). errors = append(errors, validateProtectedModuleRefs(ctx, prog)...) + // A business event message attribute named like a member its entity + // inherits (BusinessEvents.PublishedBusinessEvent's, read from the project): + // mxbuild reports the duplicate after exec wrote it (mendixlabs/mxcli#1525). + errors = append(errors, validateBusinessEventInheritedMembers(ctx, prog, sc)...) return errors, sc.warnings } diff --git a/mdl/executor/validate_business_event_members.go b/mdl/executor/validate_business_event_members.go new file mode 100644 index 0000000000..c4b62bca66 --- /dev/null +++ b/mdl/executor/validate_business_event_members.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// validateBusinessEventInheritedMembers reports a business event message +// attribute named like a member the message's entity inherits — from +// BusinessEvents.PublishedBusinessEvent or anything else up its generalization +// chain (mendixlabs/mxcli#1525, the reporter's `EventId`). The entity must +// declare one attribute per message attribute, an inherited attribute does not +// count, and it cannot redeclare one it inherits — so such a message can never be +// satisfied. Measured on mx 11.6.6 and 11.15.0 (blank apps, a stub +// PublishedBusinessEvent declaring EventId): mxbuild reports "The Business Events +// attribute 'EventId' is not present on the entity." on the service, after exec +// has written it; the same service with the attribute dropped is clean of it. +// +// The inherited members are read from the project (and from entities the script +// itself creates), never from a list kept here: the installed marketplace +// module decides what PublishedBusinessEvent declares. Where the chain cannot be +// read — the BusinessEvents module is not in the project, an ancestor does not +// resolve — nothing is reported for the part that could not be read. +// +// Matching ignores case: two members differing only in case are one member to +// Mendix. +func validateBusinessEventInheritedMembers(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { + if prog == nil || !ctx.Connected() { + return nil + } + b, _ := ctx.Backend.(entityLookupBackend) + + var errs []error + for i, stmt := range prog.Statements { + s, ok := stmt.(*ast.CreateBusinessEventServiceStmt) + if !ok { + continue + } + for _, msg := range s.Messages { + if msg.Entity == "" || len(msg.Attributes) == 0 { + continue + } + inherited := inheritedAttributeOwners(b, sc, msg.Entity) + if len(inherited) == 0 { + continue + } + for _, a := range msg.Attributes { + owner, clash := inherited[strings.ToLower(a.Name)] + if !clash { + continue + } + errs = append(errs, mdlerrors.NewValidationf( + "statement %d: business event service %s, message %s: attribute '%s' collides with "+ + "the member %s.%s that %s inherits — an inherited attribute does not count as the "+ + "entity's own, so mxbuild reports \"The Business Events attribute '%[4]s' is not present "+ + "on the entity\". Rename the message attribute and add it to the entity under that name", + i+1, s.Name.String(), msg.MessageName, a.Name, owner, a.Name, msg.Entity)) + } + } + } + return errs +} + +// inheritedAttributeOwners maps each attribute an entity inherits (lower-cased) +// to the qualified name of the ancestor declaring it. The entity's own +// attributes are not included. Each ancestor is read from the script when the +// script creates it, otherwise from the project; the walk stops at the first +// one that resolves in neither. +func inheritedAttributeOwners(b entityLookupBackend, sc *scriptContext, entityQN string) map[string]string { + out := map[string]string{} + seen := map[string]bool{strings.ToLower(entityQN): true} + parent, ok := entityParent(b, sc, entityQN) + for ok && parent != "" && !seen[strings.ToLower(parent)] { + seen[strings.ToLower(parent)] = true + attrs, found := entityOwnAttributeNames(b, sc, parent) + if !found { + break + } + for _, name := range attrs { + if _, dup := out[strings.ToLower(name)]; !dup { + out[strings.ToLower(name)] = parent + } + } + parent, ok = entityParent(b, sc, parent) + } + return out +} + +// entityParent is the generalization of entityQN ("" for none); ok is false +// when the entity resolves in neither the script nor the project. +func entityParent(b entityLookupBackend, sc *scriptContext, entityQN string) (string, bool) { + if sc != nil { + if gen, declared := sc.entityGeneralizations[strings.ToLower(entityQN)]; declared { + return gen, true + } + } + if e, found := findEntityByQN(b, entityQN); found { + return e.GeneralizationRef, true + } + return "", false +} + +// entityOwnAttributeNames lists the attributes entityQN itself declares. +func entityOwnAttributeNames(b entityLookupBackend, sc *scriptContext, entityQN string) ([]string, bool) { + if sc != nil { + for qn, decl := range sc.entityDecls { + if strings.EqualFold(qn, entityQN) { + names := make([]string, 0, len(decl.Attributes)) + for _, a := range decl.Attributes { + names = append(names, a.Name) + } + return names, true + } + } + } + e, found := findEntityByQN(b, entityQN) + if !found { + return nil, false + } + names := make([]string, 0, len(e.Attributes)) + for _, a := range e.Attributes { + names = append(names, a.Name) + } + return names, true +} diff --git a/mdl/executor/validate_business_event_members_test.go b/mdl/executor/validate_business_event_members_test.go new file mode 100644 index 0000000000..5324e3b181 --- /dev/null +++ b/mdl/executor/validate_business_event_members_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mendixlabs/mxcli#1525: a business event message attribute named like a member +// the event entity inherits from BusinessEvents.PublishedBusinessEvent (the +// reporter's `EventId`) passed check and exec, and mxbuild reported the +// duplicate. The inherited members are read from the project, never from a list +// in mxcli: the marketplace module's entity is what decides. + +// beFixture: BusinessEvents.PublishedBusinessEvent (EventId, Subject) — the +// names stand in for whatever the installed module declares — and +// Shop.PBE_Order (OrderId) extending it. withBusinessEvents=false leaves the +// BusinessEvents module out of the project. +func beFixture(t *testing.T, withBusinessEvents bool) *ExecContext { + t.Helper() + be := &model.Module{Name: "BusinessEvents"} + be.ID = nextID("bemod") + shop := &model.Module{Name: "Shop"} + shop.ID = nextID("shop") + + mkAttr := func(name string) *domainmodel.Attribute { + a := &domainmodel.Attribute{Name: name, Type: &domainmodel.StringAttributeType{}} + a.ID = nextID("attr" + name) + return a + } + pbe := &domainmodel.Entity{Name: "PublishedBusinessEvent", Persistable: true} + pbe.ID = nextID("pbe") + pbe.Attributes = []*domainmodel.Attribute{mkAttr("EventId"), mkAttr("Subject")} + order := &domainmodel.Entity{Name: "PBE_Order", Persistable: true, GeneralizationRef: "BusinessEvents.PublishedBusinessEvent"} + order.ID = nextID("order") + order.Attributes = []*domainmodel.Attribute{mkAttr("OrderId")} + + beDM := &domainmodel.DomainModel{ContainerID: be.ID, Entities: []*domainmodel.Entity{pbe}} + beDM.ID = nextID("bedm") + shopDM := &domainmodel.DomainModel{ContainerID: shop.ID, Entities: []*domainmodel.Entity{order}} + shopDM.ID = nextID("shopdm") + + modules := []*model.Module{shop} + if withBusinessEvents { + modules = append(modules, be) + } + h := mkHierarchy(modules...) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return modules, nil }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + for _, m := range modules { + if m.Name == name { + return m, nil + } + } + return nil, fmt.Errorf("no module %q", name) + }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { + switch { + case id == shop.ID: + return shopDM, nil + case id == be.ID && withBusinessEvents: + return beDM, nil + } + return nil, fmt.Errorf("no domain model %s", id) + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx +} + +func beServiceStmt(entity string, attrs ...string) *ast.CreateBusinessEventServiceStmt { + msg := &ast.BusinessEventMessageDef{MessageName: "OrderCreated", Operation: "publish", Entity: entity} + for _, a := range attrs { + msg.Attributes = append(msg.Attributes, &ast.BusinessEventAttributeDef{Name: a, TypeName: "Long"}) + } + return &ast.CreateBusinessEventServiceStmt{ + Name: ast.QualifiedName{Module: "Shop", Name: "OrderEvents"}, + Messages: []*ast.BusinessEventMessageDef{msg}, + } +} + +func runBEMemberCheck(ctx *ExecContext, stmts ...ast.Statement) []error { + prog := &ast.Program{Statements: stmts} + sc := newScriptContext() + sc.collectDefinitions(prog) + return validateBusinessEventInheritedMembers(ctx, prog, sc) +} + +func TestValidateBusinessEventMembers_InheritedNameOnProjectEntity(t *testing.T) { + errs := runBEMemberCheck(beFixture(t, true), beServiceStmt("Shop.PBE_Order", "OrderId", "EventId")) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + for _, want := range []string{"OrderCreated", "EventId", "BusinessEvents.PublishedBusinessEvent", "Shop.PBE_Order"} { + if !strings.Contains(errs[0].Error(), want) { + t.Errorf("error should mention %q: %v", want, errs[0]) + } + } +} + +// The usual script creates the event entity next to the service: the chain then +// starts in the script and continues in the project. Case differs on purpose — +// two members differing only in case are one member to Mendix. +func TestValidateBusinessEventMembers_InheritedNameOnScriptEntity(t *testing.T) { + ent := &ast.CreateEntityStmt{ + Name: ast.QualifiedName{Module: "Shop", Name: "PBE_New"}, + Generalization: &ast.QualifiedName{Module: "BusinessEvents", Name: "PublishedBusinessEvent"}, + Attributes: []ast.Attribute{{Name: "OrderId"}}, + } + errs := runBEMemberCheck(beFixture(t, true), ent, beServiceStmt("Shop.PBE_New", "OrderId", "eventid")) + if len(errs) != 1 || !strings.Contains(errs[0].Error(), "eventid") { + t.Fatalf("want one error naming eventid, got %v", errs) + } +} + +// CONTROL: the entity's own attributes are exactly what a message must declare, +// so they never collide; neither does anything when the project cannot say what +// is inherited (no BusinessEvents module) or the message names no entity. +func TestValidateBusinessEventMembers_SilentWhenNothingIsInherited(t *testing.T) { + cases := []struct { + name string + ctx *ExecContext + stmt ast.Statement + }{ + {"own attributes only", beFixture(t, true), beServiceStmt("Shop.PBE_Order", "OrderId")}, + {"module absent", beFixture(t, false), beServiceStmt("Shop.PBE_Order", "OrderId", "EventId")}, + {"no entity", beFixture(t, true), beServiceStmt("", "EventId")}, + } + for _, c := range cases { + if errs := runBEMemberCheck(c.ctx, c.stmt); len(errs) != 0 { + t.Errorf("%s: want no error, got %v", c.name, errs) + } + } +} + +// The check is wired into the --references pass. +func TestValidateBusinessEventMembers_RunsInReferencePass(t *testing.T) { + errs, _ := validateProgramWithWarnings(beFixture(t, true), + &ast.Program{Statements: []ast.Statement{beServiceStmt("Shop.PBE_Order", "OrderId", "EventId")}}) + for _, e := range errs { + if strings.Contains(e.Error(), "EventId") && strings.Contains(e.Error(), "inherit") { + return + } + } + t.Fatalf("reference pass did not report the inherited EventId: %v", errs) +} From 979cb8f504a9ad7fca464e5d9dc4f43fb995f4e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:09:37 +0000 Subject: [PATCH 07/25] fix(executor): describe keeps an untitled Data grid 2 actions column (mendixlabs/mxcli#1520) DESCRIBE PAGE left out a Data grid 2 column with no attribute and an empty header (the usual actions column, ShowContentAs: customContent) together with every widget inside it. Re-executing the unchanged output was a no-op ("Unchanged page"), but the first real edit replaced the page and deleted the column and its buttons from the model, with mx check clean. Cause: extractDataGrid2Columns took the first property holding Objects and kept a column only when it had an attribute or a caption. Fix: find the `columns` property by its resolved key and treat every object in it as a column; the old heuristic remains only for an unresolved key, widened to columns with content or a content mode. A column with neither attribute nor caption is printed with `Caption: ''`, which the writer stores as the empty header template. Measured on 11.6.6 and 11.15.0: describe -> edit -> exec keeps the column and button, mx check 0 errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...ops-untitled-datagrid2-actions-column.json | 1 + ...1520-describe-untitled-datagrid-column.mdl | 50 +++++ mdl/executor/cmd_pages_describe_output.go | 6 + mdl/executor/cmd_pages_describe_pluggable.go | 16 +- ...cmd_pages_describe_untitled_column_test.go | 186 ++++++++++++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-describe-drops-untitled-datagrid2-actions-column.json create mode 100644 mdl-examples/bug-tests/1520-describe-untitled-datagrid-column.mdl create mode 100644 mdl/executor/cmd_pages_describe_untitled_column_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-describe-drops-untitled-datagrid2-actions-column.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-describe-drops-untitled-datagrid2-actions-column.json new file mode 100644 index 0000000000..6cedba8de6 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-describe-drops-untitled-datagrid2-actions-column.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-16","symptom":"describe page left out a Data grid 2 column with no attribute and an empty header — the usual actions column, `column (Caption: '', ShowContentAs: customContent) { actionbutton … }` — and every widget in it; re-exec of the unchanged output said \"Unchanged page\", but the first real edit replaced the page and the column and its buttons were gone from the BSON, mx check 0 errors at both ends (mendixlabs/mxcli#1520).","cause":"extractDataGrid2Columns took the first property holding Objects and kept a column only `if col.Attribute != \"\" || col.Caption != \"\"` — a heuristic to tell columns from other object lists that also threw away the one column shape with neither. Now the `columns` property is found by its resolved key and every object in it is a column; the heuristic stays only for an unresolved key, widened to content/content mode. outputDataGrid2ColumnV3 prints `Caption: ''` for a column with neither, which the writer stores as the empty template (applyColumnHeaderFallback).","file":"`mdl/executor/cmd_pages_describe_pluggable.go` (extractDataGrid2Columns), `cmd_pages_describe_output.go` (outputDataGrid2ColumnV3)","insight":"Write elision hides a describe drop: an unchanged re-exec is a no-op, so only an edited round trip shows the loss — the test must make a real edit and assert the edit landed (control) before asserting the column survived. `Caption: ''` with customContent is CE0463-free on both 11.6.6 and 11.15.0 (measured: mx check 0 errors), so ako/mxcli#1401 item 1 does not reproduce on current main.","refs":["mendixlabs/mxcli#1520","mendixlabs/mxcli#1519"]} diff --git a/mdl-examples/bug-tests/1520-describe-untitled-datagrid-column.mdl b/mdl-examples/bug-tests/1520-describe-untitled-datagrid-column.mdl new file mode 100644 index 0000000000..25d4d8cc58 --- /dev/null +++ b/mdl-examples/bug-tests/1520-describe-untitled-datagrid-column.mdl @@ -0,0 +1,50 @@ +-- ============================================================================ +-- Bug test: mendixlabs/mxcli#1520 — DESCRIBE PAGE drops an untitled Data grid 2 +-- actions column +-- ============================================================================ +-- +-- A Data grid 2 column with no attribute and an empty header — the usual +-- actions column, `column (Caption: '', ShowContentAs: customContent)` — was +-- left out of DESCRIBE PAGE together with every widget inside it. Re-running +-- the unchanged output was reported "Unchanged page", but any real edit to it +-- replaced the page and deleted the column and its buttons from the model. +-- +-- To verify: +-- 1. mxcli exec this script, then: describe page Bug1520.ItemList +-- -> the grid lists THREE columns, the last one +-- `column (Caption: '', ShowContentAs: customContent) { actionbutton btnEdit … }` +-- 2. Change the 'Name' caption in that output to 'Item name' and exec it. +-- describe again -> still three columns, btnEdit still present. +-- 3. mx check: 0 errors. +-- ============================================================================ + +mdl 1; + +create module Bug1520; + +create persistent entity Bug1520.Item ( + Name: String(200), + Code: String(50) +); + +create microflow Bug1520.ACT_Item_Edit ( + $Item: Bug1520.Item +) +begin + return; +end; + +create page Bug1520.ItemList ( + Title: 'Items', + Layout: Atlas_Core.Atlas_Default +) { + datagrid dgItems (DataSource: database from Bug1520.Item) { + column (Attribute: Name, Caption: 'Name') + column (Attribute: Code, Caption: 'Code') + column (Caption: '', ShowContentAs: customContent) { + actionbutton btnEdit (Caption: 'Edit', Action: call microflow Bug1520.ACT_Item_Edit(Item = $currentObject)) + } + } +}; + +describe page Bug1520.ItemList; diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 707f06d672..22e5fb1322 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1203,6 +1203,12 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol } if col.Caption != "" { props = append(props, fmt.Sprintf("Caption: %s", mdlQuote(ctx, col.Caption))) + } else if col.Attribute == "" { + // An untitled actions column (mendixlabs/mxcli#1520): say the empty + // header outright rather than leave the column with no identifying + // property. The writer stores `Caption: ''` as the empty template + // Studio Pro writes — the shape that was read here. + props = append(props, "Caption: ''") } if len(col.CaptionParams) > 0 { props = append(props, fmt.Sprintf("CaptionParams: (%s)", strings.Join(formatParametersV3(col.CaptionParams), ", "))) diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 33bf1f8915..9a688efe56 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -215,6 +215,8 @@ func extractDataGrid2Columns(ctx *ExecContext, w map[string]any, entityContext . // Build column property key map from Type.ObjectType.PropertyTypes -> columns -> ValueType.ObjectType.PropertyTypes colPropKeyMap := buildColumnPropertyKeyMap(ctx, w) + // The widget's own property keys, to find the `columns` list by name. + propTypeKeyMap := buildPropertyTypeKeyMap(w, false) // Search through properties for columns props := getBsonArrayElements(obj["Properties"]) @@ -223,6 +225,10 @@ func extractDataGrid2Columns(ctx *ExecContext, w map[string]any, entityContext . if !ok { continue } + propKey := propTypeKeyMap[extractBinaryID(propMap["TypePointer"])] + if propKey != "" && propKey != "columns" { + continue + } value, ok := propMap["Value"].(map[string]any) if !ok { continue @@ -244,7 +250,15 @@ func extractDataGrid2Columns(ctx *ExecContext, w map[string]any, entityContext . continue } col := extractDataGrid2Column(ctx, colMap, colPropKeyMap, entCtx) - if col.Attribute != "" || col.Caption != "" { + // Every object of the resolved `columns` list is a column. The + // actions column has no attribute and an empty header, and keeping + // only columns with one of those dropped it with all its widgets, + // so the next describe -> edit -> exec deleted it + // (mendixlabs/mxcli#1520). Only when the key did not resolve does + // the heuristic separate columns from another object list, widened + // to a column that carries content or a content mode. + if propKey == "columns" || col.Attribute != "" || col.Caption != "" || + col.ShowContentAs != "" || len(col.ContentWidgets) > 0 || len(col.FilterWidgets) > 0 { columns = append(columns, col) } } diff --git a/mdl/executor/cmd_pages_describe_untitled_column_test.go b/mdl/executor/cmd_pages_describe_untitled_column_test.go new file mode 100644 index 0000000000..9e2757d01d --- /dev/null +++ b/mdl/executor/cmd_pages_describe_untitled_column_test.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1520 (ako/mxcli#1402): DESCRIBE PAGE dropped a Data grid 2 +// column that has no attribute and an empty header — the usual actions column, +// `column (Caption: ”, ShowContentAs: customContent) { actionbutton … }` — +// together with every widget inside it. The column reader kept a column only +// when it had an attribute or a caption. +// +// The round trip is the damage: re-executing the unchanged output is reported +// "Unchanged page" (the stored pluggable passes through), but the first real +// edit to the described text replaces the page, and the column and its buttons +// are gone from the BSON — check, exec and mx check clean at both ends. +package executor + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// buildDataGridWithUntitledActionsColumn is a DataGrid2 with two columns: an +// attribute column, and an actions column whose header is an empty template, +// with no attribute and one button as custom content. +func buildDataGridWithUntitledActionsColumn() map[string]any { + const ( + idColumns = "type-id-columns" + idHeader = "type-id-header" + idAttribute = "type-id-attribute" + idShowContentAs = "type-id-showcontentas" + idContent = "type-id-content" + ) + colProp := func(typePointer string, value map[string]any) map[string]any { + return map[string]any{"TypePointer": typePointer, "Value": value} + } + header := func(text string) map[string]any { + items := []any{} + if text != "" { + items = append(items, map[string]any{"Text": text}) + } + return map[string]any{"TextTemplate": map[string]any{"Template": map[string]any{"Items": items}}} + } + return map[string]any{ + "Name": "dgTest", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.datagrid.Datagrid", + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{ + "$ID": idColumns, "PropertyKey": "columns", + "ValueType": map[string]any{ + "ObjectType": map[string]any{ + "PropertyTypes": []any{ + map[string]any{"$ID": idHeader, "PropertyKey": "header"}, + map[string]any{"$ID": idAttribute, "PropertyKey": "attribute"}, + map[string]any{"$ID": idShowContentAs, "PropertyKey": "showContentAs"}, + map[string]any{"$ID": idContent, "PropertyKey": "content"}, + }, + }, + }, + }, + }, + }, + }, + "Object": map[string]any{ + "Properties": []any{ + map[string]any{ + "TypePointer": idColumns, + "Value": map[string]any{ + "Objects": []any{ + map[string]any{"Properties": []any{ + colProp(idAttribute, map[string]any{"AttributeRef": map[string]any{"Attribute": "MyModule.Item.Name"}}), + colProp(idHeader, header("Name")), + colProp(idShowContentAs, map[string]any{"PrimitiveValue": "attribute"}), + }}, + map[string]any{"Properties": []any{ + colProp(idContent, map[string]any{ + "Widgets": []any{ + map[string]any{"$Type": "Forms$CheckBox", "Name": "cbSelect"}, + }, + }), + colProp(idHeader, header("")), + colProp(idShowContentAs, map[string]any{"PrimitiveValue": "customContent"}), + }}, + }, + }, + }, + }, + }, + } +} + +// The read half: the untitled, attribute-less column is a column. +func TestDataGrid2Column_KeepsUntitledCustomContentColumn(t *testing.T) { + cols := extractDataGrid2Columns(nil, buildDataGridWithUntitledActionsColumn()) + if len(cols) != 2 { + t.Fatalf("expected 2 columns, got %d: %+v — the actions column with an empty header "+ + "and no attribute was dropped (mendixlabs/mxcli#1520)", len(cols), cols) + } + col := cols[1] + if col.ShowContentAs != "customContent" { + t.Errorf("ShowContentAs = %q, want customContent", col.ShowContentAs) + } + if len(col.ContentWidgets) != 1 || col.ContentWidgets[0].Name != "cbSelect" { + t.Errorf("content widgets = %+v, want one widget named cbSelect", col.ContentWidgets) + } +} + +// The emit half: the column is printed with an explicit empty caption and its +// content, so describe -> exec writes the same column back. +func TestDataGrid2Column_EmitsUntitledCustomContentColumn(t *testing.T) { + cols := extractDataGrid2Columns(nil, buildDataGridWithUntitledActionsColumn()) + var buf bytes.Buffer + ctx := &ExecContext{Output: &buf} + outputWidgetMDLV3(ctx, rawWidget{ + Type: "CustomWidgets$CustomWidget", + RenderMode: "datagrid2", + Name: "dgTest", + WidgetID: "com.mendix.widget.web.datagrid.Datagrid", + DataGridColumns: cols, + }, 0) + out := buf.String() + if !strings.Contains(out, "column (Caption: '', ShowContentAs: customContent) {") { + t.Errorf("untitled actions column missing from DESCRIBE output:\n%s", out) + } + if !strings.Contains(out, "cbSelect") { + t.Errorf("the actions column's widget is missing from DESCRIBE output:\n%s", out) + } +} + +// End to end on the Studio Pro-authored PedApp fixture: create the page, +// describe it, make one real edit to the described text (a caption of ANOTHER +// column), execute it — the actions column and its button must still be in the +// stored BSON. +func TestDescribePage_UntitledActionsColumnRoundTrips(t *testing.T) { + exec, out := openPedAppWithWidgets(t) + src := `create page MyFirstModule.ActionsCol (Title: 'Actions', Layout: Atlas_Core.Atlas_Default) { + datagrid dgAccounts (DataSource: database from Administration.Account) { + column (Attribute: FullName, Caption: 'Full name') + column (Attribute: Name, Caption: 'Login') + column (Caption: '', ShowContentAs: customContent) { + actionbutton btnEdit (Caption: 'Edit', Action: microflow Administration.NewAccount) + } + } +};` + if err := afRun(t, exec, src); err != nil { + t.Fatalf("create page: %v", err) + } + if !rawPageContains(t, exec, "MyFirstModule.ActionsCol", "btnEdit") { + t.Fatal("precondition: btnEdit not stored by create page") + } + + described := describeOn(t, exec, out, "MyFirstModule.ActionsCol") + if !strings.Contains(described, "column (Caption: '', ShowContentAs: customContent) {") || + !strings.Contains(described, "actionbutton btnEdit") { + t.Fatalf("describe dropped the untitled actions column (mendixlabs/mxcli#1520):\n%s", described) + } + + edited := strings.Replace(described, "Caption: 'Login'", "Caption: 'User name'", 1) + if edited == described { + t.Fatalf("fixture: no Login caption to edit in:\n%s", described) + } + if err := afRun(t, exec, edited); err != nil { + t.Fatalf("re-exec edited describe output: %v\n%s", err, edited) + } + if !rawPageContains(t, exec, "MyFirstModule.ActionsCol", "User name") { + t.Fatal("the edit did not land — the test proves nothing without it") + } + if !rawPageContains(t, exec, "MyFirstModule.ActionsCol", "btnEdit") { + t.Errorf("describe -> edit -> exec deleted the actions column's button from the BSON") + } + after := describeOn(t, exec, out, "MyFirstModule.ActionsCol") + if n := strings.Count(after, "\n column ("); n != 3 { + t.Errorf("after the round trip the grid has %d columns, want 3:\n%s", n, after) + } +} + +func rawPageContains(t *testing.T, exec *Executor, qn, needle string) bool { + t.Helper() + ctx := exec.newExecContext(context.Background()) + info, err := ctx.Backend.GetRawUnitByName("page", qn) + if err != nil || info == nil { + t.Fatalf("raw page %s: %v", qn, err) + } + return bytes.Contains(info.Contents, []byte(needle)) +} From 50b75bcb82875af28e3b5c974f6fd3513d85511a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:16:26 +0000 Subject: [PATCH 08/25] fix(executor): refuse a call workflow mapping not keyed WorkflowContext (mendixlabs/mxcli#1530) A workflow has exactly one parameter and mxcli always stores it as WorkflowContext; the name in a `parameter $Task: M.Item` header is only an alias. buildCallWorkflowActivity wrote the mapping key as . verbatim, so `call workflow M.WF_Child(Task = $WorkflowContext)` passed check and exec and failed the build with CE1613 "The selected parameter 'M.WF_Child.Task' no longer exists" (measured on 11.6.6 and 11.15.0). The syntax help taught that form. New rule MDL-WF18 refuses any other key at check time (create and the activities an alter inserts) and at exec, naming WorkflowContext; a casing variant is accepted and written in the stored spelling. The syntax-help example, the quick reference and the write-workflows skill now show `(WorkflowContext = ...)`. Measured: the corrected form is 0 errors on both versions; the repro is refused by check and by exec --no-check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...apping-key-not-workflowcontext-ce1613.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 26 ++-- cmd/mxcli/syntax/features_workflow.go | 12 +- docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- .../1530-call-workflow-parameter-key.mdl | 39 ++++++ mdl/executor/cmd_alter_workflow.go | 3 + mdl/executor/cmd_workflows_write.go | 16 ++- mdl/executor/validate_workflow.go | 5 +- mdl/executor/validate_workflow_call.go | 58 ++++++++ mdl/executor/validate_workflow_call_test.go | 130 ++++++++++++++++++ 10 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-call-workflow-mapping-key-not-workflowcontext-ce1613.json create mode 100644 mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl create mode 100644 mdl/executor/validate_workflow_call.go create mode 100644 mdl/executor/validate_workflow_call_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-call-workflow-mapping-key-not-workflowcontext-ce1613.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-call-workflow-mapping-key-not-workflowcontext-ce1613.json new file mode 100644 index 0000000000..2d9b25e0eb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-call-workflow-mapping-key-not-workflowcontext-ce1613.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "With the child declared `parameter $Task: M.Item`, the parent's `call workflow M.WF_Child(Task = $WorkflowContext);` passed `check --references` and `exec`, then mx check reported CE1613 \"The selected parameter 'M.WF_Child.Task' no longer exists\" (11.6.6 and 11.15.0); `mxcli syntax workflow call-workflow` taught that form with `(Request = $WorkflowContext)` (mendixlabs/mxcli#1530).", "cause": "buildCallWorkflowActivity writes the mapping key as . verbatim, but a workflow has exactly one parameter and mxcli always stores it as WorkflowContext (the header's `$Task` is only an alias, see contextExprNormalizer). So any other key names a parameter that cannot exist, and nothing between the parser and the build compared the key against that fixed name.", "file": "mdl/executor/validate_workflow_call.go (MDL-WF18, wired into ValidateWorkflow, ValidateAlterWorkflow, execCreateWorkflow and execAlterWorkflow); cmd_workflows_write.go writes a casing variant as WorkflowContext; cmd/mxcli/syntax/features_workflow.go example", "insight": "The alias that makes `parameter $Task` work on the CHILD side is exactly what makes `Task = ` look right on the CALLER side: the author sees the name they declared and reuses it. Where a name is an alias for a fixed stored name, every place that writes a reference to it needs the same alias table or a refusal - grep for the stored constant (workflowContextVar) and check each writer. Refused rather than renamed: a key naming some other parameter is a misunderstanding worth stating, and the wrong syntax-help example would otherwise keep teaching it. Measured: the repro is CE1613 on 11.6.6 and 11.15.0; the same child header with the key `WorkflowContext` is 0 errors on both.", "refs": ["mdl/executor/validate_workflow_call_test.go", "mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl"], "ce": ["CE1613"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 83ee2f54eb..cfcfd33009 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -12,17 +12,17 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel |---|---| | 35-46 | When to Use This Skill | | 47-110 | Syntax — CREATE WORKFLOW | -| 111-282 | Activities | -| 283-289 | DROP WORKFLOW | -| 290-355 | ALTER WORKFLOW | -| 356-376 | DESCRIBE round-trip | -| 377-397 | Activity names, and why `jump to` depends on them | -| 398-418 | Rewriting an existing workflow | -| 419-506 | Microflow statements for workflow tasks | -| 507-527 | System-module enumerations are synthesized, not stored | -| 528-644 | Platform rules | -| 645-675 | Observing a running workflow | -| 676-685 | Validate before presenting | +| 111-286 | Activities | +| 287-293 | DROP WORKFLOW | +| 294-359 | ALTER WORKFLOW | +| 360-380 | DESCRIBE round-trip | +| 381-401 | Activity names, and why `jump to` depends on them | +| 402-422 | Rewriting an existing workflow | +| 423-510 | Microflow statements for workflow tasks | +| 511-531 | System-module enumerations are synthesized, not stored | +| 532-648 | Platform rules | +| 649-679 | Observing a running workflow | +| 680-689 | Validate before presenting | # Mendix Workflows Skill @@ -185,6 +185,10 @@ begin end workflow; ``` +A `call workflow` mapping is always keyed `WorkflowContext` — the one parameter a +workflow has, whatever its header calls it: `(WorkflowContext = $WorkflowContext/M.A_B/M.B)`. +Any other key is CE1613 (`MDL-WF18`); a path must name the target entity (else CE0117). + **Notes** attach to an activity with `@annotation '…'` on the line before it, as in a microflow; the workflow's own note is the header clause `annotation '…'`, and an event sub-process takes `@annotation` before diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 2abcf253f9..ae4d5ef7a2 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -313,8 +313,16 @@ func init() { Keywords: []string{ "call workflow", "sub-workflow", "nested workflow", }, - Syntax: "CALL WORKFLOW Module.WF[( = , ...)] [AS ] [CAPTION ''];\n\n-- This is the workflow ACTIVITY. To start a workflow from a microflow\n-- ($Wf = CALL WORKFLOW Module.WF ($Obj);) see microflow.workflow-actions.", - Example: "CALL WORKFLOW HR.SubApproval CAPTION 'Delegate to sub-process';\n\nCALL WORKFLOW HR.SubApproval(Request = $WorkflowContext) AS callWf1;", + Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = )] [AS ] [CAPTION ''];\n\n" + + "-- A workflow has exactly one parameter and Mendix stores it as WorkflowContext,\n" + + "-- whatever name the called workflow's `PARAMETER $Name: ...` header declares — so\n" + + "-- the mapping key is always WorkflowContext. Any other key is CE1613 (MDL-WF18).\n" + + "-- Omitted, the call passes the caller's own $WorkflowContext.\n\n" + + "-- This is the workflow ACTIVITY. To start a workflow from a microflow\n-- ($Wf = CALL WORKFLOW Module.WF ($Obj);) see microflow.workflow-actions.", + Example: "CALL WORKFLOW HR.SubApproval CAPTION 'Delegate to sub-process';\n\n" + + "CALL WORKFLOW HR.SubApproval(WorkflowContext = $WorkflowContext) AS callWf1;\n\n" + + "-- pass an associated object: the path names the association AND its target entity\n" + + "CALL WORKFLOW HR.ReviewLine(WorkflowContext = $WorkflowContext/HR.Request_Line/HR.Line) AS callWf2;", SeeAlso: []string{"workflow.create", "workflow.call-microflow", "microflow.workflow-actions"}, }) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0aba7bb301..649b3aec2e 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -753,7 +753,7 @@ mandatory and a misplaced clause failed with a token error - `check --references` reports these before anything is written; `exec` refuses the workflow statement itself (Mendix 11+). - `call microflow Mod.MF[( = , ...)] [as ] [caption ''] [outcomes '' -> { } ...];` - `call agent microflow Mod.MF [as ] [caption ''] [with ( = '', ...)] [outcomes … -> { } ...];` — an **AI agent task** (Mendix 11.9+): the call-microflow statement stored as `Workflows$AIAgentTaskActivity`. Its microflow must take at least one parameter (CE1590). -- `call workflow Mod.WF[( = , ...)] [as ] [caption ''];` +- `call workflow Mod.WF[(WorkflowContext = )] [as ] [caption ''];` — the key is always `WorkflowContext`, the one parameter a workflow has, whatever its header names it; any other key is CE1613 (MDL-WF18). Omitted, the caller's `$WorkflowContext` is passed. - `decision [] [] [caption ''] outcomes -> { } ...;` — the expression is bare; a decision's condition, a timer and a due date written in a string (`decision ''`) warn MDL-DEPR080 - `parallel split [] path 1 { } path 2 { };` - `jump to ;` diff --git a/mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl b/mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl new file mode 100644 index 0000000000..b81c6eb69c --- /dev/null +++ b/mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl @@ -0,0 +1,39 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1530 - a call workflow mapping keyed on +-- anything but WorkflowContext builds to CE1613 +-- ============================================================================ +-- +-- Symptom: +-- With the child declared as `parameter $Task: M.Item`, the parent's +-- `call workflow M.WF_Child(Task = $WorkflowContext);` passed check and exec, +-- then mx check reported CE1613 "The selected parameter 'M.WF_Child.Task' no +-- longer exists" (11.6.6 and 11.15.0). A workflow has one parameter and Mendix +-- stores it as WorkflowContext; the header's `$Task` is only an alias. +-- +-- After fix: +-- check and exec refuse that key as MDL-WF18, naming WorkflowContext. The +-- form below — the same child header, the mapping keyed WorkflowContext — +-- checks clean and builds with 0 errors. +-- +-- The refused form, kept out of the script so it stays checkable: +-- call workflow M.WF_Child(Task = $WorkflowContext); +-- +-- Usage: +-- mxcli check mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl +-- mxcli exec mdl-examples/bug-tests/1530-call-workflow-parameter-key.mdl -p app.mpr +-- ============================================================================ + +create module M; +create persistent entity M.Item (Name: string(100)); + +create workflow M.WF_Child + parameter $Task: M.Item +begin +end workflow; + +create workflow M.WF_Parent + parameter $WorkflowContext: M.Item +begin + call workflow M.WF_Child(WorkflowContext = $WorkflowContext); +end workflow; diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index 2547287db4..d487b21fd0 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -32,6 +32,9 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if vs := completionRuleViolations(alterWorkflowAddedActivities(s), workflowLocation(s.Name)); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } + if vs := callWorkflowParameterViolations(alterWorkflowAddedActivities(s), workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("%s [%s]\n → %s", vs[0].Message, vs[0].RuleID, vs[0].Suggestion) + } // Same exec-side guard as CREATE WORKFLOW: ALTER had no reference validation // at all, so an inserted activity could name a microflow that exists nowhere diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 01848e9e19..300d3ffee7 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -74,6 +74,11 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { if vs := ValidateWorkflowEventTypes(s); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } + // A mapping key other than WorkflowContext is CE1613 at build time + // (mendixlabs/mxcli#1530); refused here for the #833 reason too. + if vs := callWorkflowParameterViolations(workflowStatementActivities(s), workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("%s [%s]\n → %s", vs[0].Message, vs[0].RuleID, vs[0].Suggestion) + } if err := checkVersionGates(ctx, s); err != nil { return err } @@ -620,11 +625,18 @@ func buildCallWorkflowActivity(n *ast.WorkflowCallWorkflowNode) *workflows.CallW act.ParameterExpression = "$WorkflowContext" // Explicit parameter mappings from MDL WITH clause - // BSON requires fully qualified: Module.Workflow.ParamName + // BSON requires fully qualified: Module.Workflow.ParamName. The only + // parameter a workflow has is stored as WorkflowContext; any other key is + // refused before this (MDL-WF18), and a casing variant is written in the + // stored spelling, as the context variable is in expressions. wfQN := n.Workflow.Module + "." + n.Workflow.Name for _, pm := range n.ParameterMappings { + param := pm.Parameter + if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(param), "$"), workflowContextVar) { + param = workflowContextVar + } mapping := &workflows.ParameterMapping{ - Parameter: wfQN + "." + pm.Parameter, + Parameter: wfQN + "." + param, Expression: pm.Expression, } mapping.BaseElement.ID = model.ID(types.GenerateID()) diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index f197fa1bf2..40cad86886 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -59,6 +59,7 @@ var ( // - 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) // - MDL-WF08..11: where `end workflow` may go, and `return` (see validate_workflow_end.go) +// - MDL-WF18: a `call workflow` mapping keyed on anything but WorkflowContext (CE1613) func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { var out []linter.Violation loc := linter.Location{ @@ -120,6 +121,7 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { }) } }) + out = append(out, callWorkflowParameterViolations(workflowStatementActivities(stmt), loc)...) out = append(out, ValidateWorkflowJumpTargets(stmt)...) out = append(out, ValidateWorkflowEnds(stmt)...) out = append(out, ValidateWorkflowEventTypes(stmt)...) @@ -366,7 +368,7 @@ func ValidateAlterWorkflow(stmt *ast.AlterWorkflowStmt) []linter.Violation { DocumentName: stmt.Name.Name, } - // MDL-WF06 over the activities the statement adds. + // MDL-WF06 and MDL-WF18 over the activities the statement adds. var added []ast.WorkflowActivityNode for _, op := range stmt.Operations { switch o := op.(type) { @@ -386,6 +388,7 @@ func ValidateAlterWorkflow(stmt *ast.AlterWorkflowStmt) []linter.Violation { added = append(added, o.Activities...) } } + out = append(out, callWorkflowParameterViolations(added, loc)...) walkWorkflowActivities(added, func(a ast.WorkflowActivityNode) { switch n := a.(type) { case *ast.WorkflowDecisionNode: diff --git a/mdl/executor/validate_workflow_call.go b/mdl/executor/validate_workflow_call.go new file mode 100644 index 0000000000..921070843f --- /dev/null +++ b/mdl/executor/validate_workflow_call.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// callWorkflowParameterRule (MDL-WF18): a `call workflow` mapping keyed on +// anything but WorkflowContext. +// +// A workflow has exactly one parameter, and it is stored as WorkflowContext +// whatever variable name the header declares (`parameter $Task: M.Item` is an +// alias, see contextExprNormalizer). The mapping key is written as +// ., so `call workflow M.WF_Child(Task = $WorkflowContext)` +// stored M.WF_Child.Task — passed check and exec, then failed the build. +// Measured, one parent + child per blank project, verdict = the mx check line: +// +// (Task = $WorkflowContext) 11.6.6, 11.15.0 CE1613 "The selected parameter 'M.WF_Child.Task' no longer exists." +// (WorkflowContext = $WorkflowContext) 11.6.6, 11.15.0 0 errors +// +// Refused rather than renamed: a key that names some other parameter is a +// misunderstanding worth stating, and the corrected spelling is what describe +// prints back (mendixlabs/mxcli#1530). +const callWorkflowParameterRule = "MDL-WF18" + +// callWorkflowParameterViolations applies MDL-WF18 to every `call workflow` in +// activities, nested ones included. +func callWorkflowParameterViolations(activities []ast.WorkflowActivityNode, loc linter.Location) []linter.Violation { + var out []linter.Violation + walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) { + n, ok := a.(*ast.WorkflowCallWorkflowNode) + if !ok { + return + } + qn := n.Workflow.Module + "." + n.Workflow.Name + for _, pm := range n.ParameterMappings { + key := strings.TrimPrefix(strings.TrimSpace(pm.Parameter), "$") + if strings.EqualFold(key, workflowContextVar) { + continue + } + out = append(out, linter.Violation{ + RuleID: callWorkflowParameterRule, + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("call workflow %s maps parameter '%s', but a workflow has one parameter and Mendix always stores it as WorkflowContext — "+ + "'%s.%s' does not exist and the build fails (CE1613)", qn, key, qn, key), + Suggestion: fmt.Sprintf("Key the mapping on WorkflowContext: `call workflow %s(WorkflowContext = $WorkflowContext)`. "+ + "A name declared in a workflow's `parameter $Name: …` header is only a local alias for it.", qn), + }) + } + }) + return out +} diff --git a/mdl/executor/validate_workflow_call_test.go b/mdl/executor/validate_workflow_call_test.go new file mode 100644 index 0000000000..f1e3f168f8 --- /dev/null +++ b/mdl/executor/validate_workflow_call_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// mendixlabs/mxcli#1530. A workflow has exactly one parameter, and Mendix stores +// it as WorkflowContext whatever variable name the header declares — so +// `call workflow M.WF_Child(Task = $WorkflowContext)` wrote the mapping key +// M.WF_Child.Task, which `check` and `exec` accepted and the build rejected: +// CE1613 "The selected parameter 'M.WF_Child.Task' no longer exists" (measured +// on 11.6.6 and 11.15.0). + +const callWorkflowWrongKey = `create workflow M.WF_Parent + parameter $WorkflowContext: M.Item +begin + call workflow M.WF_Child(Task = $WorkflowContext); +end workflow;` + +func callWorkflowRuleHits(vs []linter.Violation) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == callWorkflowParameterRule { + out = append(out, v) + } + } + return out +} + +func TestValidateWorkflow_CallWorkflowParameterNotWorkflowContext(t *testing.T) { + hits := callWorkflowRuleHits(ValidateWorkflow(parseWorkflowStmt(t, callWorkflowWrongKey))) + if len(hits) != 1 { + t.Fatalf("want 1 %s violation, got %d", callWorkflowParameterRule, len(hits)) + } + v := hits[0] + if v.Severity != linter.SeverityError { + t.Errorf("severity = %v, want error", v.Severity) + } + for _, want := range []string{"Task", "M.WF_Child.Task", "CE1613"} { + if !strings.Contains(v.Message, want) { + t.Errorf("message does not name %q: %s", want, v.Message) + } + } + if !strings.Contains(v.Suggestion, "WorkflowContext = $WorkflowContext") { + t.Errorf("suggestion does not show the key that builds: %s", v.Suggestion) + } +} + +// Controls: the stored name, in any casing, and a call with no mapping at all. +func TestValidateWorkflow_CallWorkflowContextKeyClean(t *testing.T) { + for _, call := range []string{ + "call workflow M.WF_Child(WorkflowContext = $WorkflowContext);", + "call workflow M.WF_Child(workflowContext = $WorkflowContext);", + "call workflow M.WF_Child;", + } { + src := strings.Replace(callWorkflowWrongKey, "call workflow M.WF_Child(Task = $WorkflowContext);", call, 1) + if hits := callWorkflowRuleHits(ValidateWorkflow(parseWorkflowStmt(t, src))); len(hits) != 0 { + t.Errorf("%s: unexpected %s: %s", call, callWorkflowParameterRule, hits[0].Message) + } + } +} + +// The rule reaches a call nested in an outcome, and one ALTER WORKFLOW inserts. +func TestValidateWorkflow_CallWorkflowParameterNestedAndAlter(t *testing.T) { + nested := `create workflow M.WF_Parent + parameter $WorkflowContext: M.Item +begin + decision '$WorkflowContext/Name != empty' + outcomes + true -> { call workflow M.WF_Child(Request = $WorkflowContext); } + false -> { } + ; +end workflow;` + if hits := callWorkflowRuleHits(ValidateWorkflow(parseWorkflowStmt(t, nested))); len(hits) != 1 { + t.Errorf("nested call: want 1 %s violation, got %d", callWorkflowParameterRule, len(hits)) + } + + prog := parseMDL(t, `alter workflow M.WF_Parent { + insert after start { call workflow M.WF_Child(Request = $WorkflowContext); } +};`) + alter, ok := prog.Statements[0].(*ast.AlterWorkflowStmt) + if !ok { + t.Fatalf("statement is %T", prog.Statements[0]) + } + if hits := callWorkflowRuleHits(ValidateAlterWorkflow(alter)); len(hits) != 1 { + t.Errorf("alter insert: want 1 %s violation, got %d", callWorkflowParameterRule, len(hits)) + } +} + +// exec is reachable without check (-c, the REPL, --no-check), so it refuses too, +// before anything is written. +func TestExecCreateWorkflow_RefusesCallWorkflowParameterNotWorkflowContext(t *testing.T) { + wrote := false + mb := &mock.MockBackend{IsConnectedFunc: func() bool { return true }} + mb.CreateWorkflowFunc = func(*workflows.Workflow) error { wrote = true; return nil } + ctx, _ := newMockCtx(t, withBackend(mb)) + err := execCreateWorkflow(ctx, parseWorkflowStmt(t, callWorkflowWrongKey)) + if err == nil { + t.Fatal("exec wrote a call workflow mapping keyed on a parameter that does not exist") + } + if !strings.Contains(err.Error(), callWorkflowParameterRule) || !strings.Contains(err.Error(), "WorkflowContext") { + t.Errorf("refusal does not name the rule and the right key: %v", err) + } + if wrote { + t.Error("the workflow was written before the refusal") + } +} + +// A casing variant of the stored name is accepted, so the key written must be +// the stored spelling, as for the context variable in expressions. +func TestBuildCallWorkflowActivity_NormalizesContextKeyCasing(t *testing.T) { + act := buildCallWorkflowActivity(&ast.WorkflowCallWorkflowNode{ + Workflow: ast.QualifiedName{Module: "M", Name: "WF_Child"}, + ParameterMappings: []ast.WorkflowParameterMappingNode{{Parameter: "workflowcontext", Expression: "$WorkflowContext"}}, + }) + if len(act.ParameterMappings) != 1 { + t.Fatalf("mappings = %d, want 1", len(act.ParameterMappings)) + } + if got := act.ParameterMappings[0].Parameter; got != "M.WF_Child.WorkflowContext" { + t.Errorf("mapping key = %q, want M.WF_Child.WorkflowContext", got) + } +} From 5c01fe9003eaf5e7222a5e7a36ae148f1724c038 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:23:56 +0000 Subject: [PATCH 09/25] fix(navigation): an omitted clause keeps the stored value (mendixlabs/mxcli#1522) `create or modify navigation Responsive { menu item ... }` cleared the profile's home page, role-based home pages, login page and not-found page, so the next build failed with CE0527 "Select a default home page for this profile". navPatchWebProfile wrote those four fields unconditionally from the spec, while the menu, sync block, ON SYNC ERROR and PROGRESSIVE WEB APP were already left alone when omitted. navPatchNativeProfile rebuilt RoleBasedNativeHomePages the same way. An omitted clause now keeps the stored value for every field. `home ... for ` replaces that role's home page only; a role the statement does not mention keeps its own (navMergeRoleHomes). MDL has no clause that clears a home, login or not-found page, so such a page can no longer be removed by leaving it out. describe -> exec is unaffected, since describe states every clause. Both patchers now work on a copy of the stored profile, because navSetField writes in place. The docs said both "fully replaces" and "omitting the block leaves it alone"; syntax help, skills and docs-site now describe the one rule. Measured with mx check after a menu-only statement on a profile with a home page: 11.15.0 and 11.6.6 both CE0527 (1 error) before, 0 errors after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...omitted-clause-cleared-home-page-1522.json | 1 + .../skills/mendix/choose-edit-mode/SKILL.md | 7 +- .../skills/mendix/manage-navigation/SKILL.md | 6 +- .../mendix/migrate-design-prototype/SKILL.md | 21 ++- cmd/mxcli/syntax/features_misc.go | 7 + docs-site/src/appendixes/quick-reference.md | 2 +- docs-site/src/language/navigation-profiles.md | 6 +- .../reference/navigation/alter-navigation.md | 12 +- ...navigation-omitted-clause-keeps-stored.mdl | 47 +++++ .../navigation_omitted_clause_test.go | 177 ++++++++++++++++++ mdl/backend/modelsdk/navigation_write.go | 94 ++++++++-- mdl/executor/cmd_navigation.go | 4 +- 12 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-16-navigation-omitted-clause-cleared-home-page-1522.json create mode 100644 mdl-examples/bug-tests/1522-navigation-omitted-clause-keeps-stored.mdl create mode 100644 mdl/backend/modelsdk/navigation_omitted_clause_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-navigation-omitted-clause-cleared-home-page-1522.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-navigation-omitted-clause-cleared-home-page-1522.json new file mode 100644 index 0000000000..ac9ec9c745 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-navigation-omitted-clause-cleared-home-page-1522.json @@ -0,0 +1 @@ +{"area": "mdl/backend/modelsdk", "date": "2026-10-16", "symptom": "`create or modify navigation Responsive { menu item … }` (a statement that states only a menu) passes check and exec, then the profile has no home page and mx check reports CE0527 \"Select a default home page for this profile\"; the role home pages, login page and not-found page are gone too", "cause": "navPatchWebProfile wrote HomePage, HomeItems, LoginPageSettings and NotFoundHomepage unconditionally from the spec — an empty spec field cleared the stored one — while Menu, OfflineEntityConfigs, ThrowPartialSyncError and ProgressiveWebAppSettings were already guarded (HasMenu/HasSync/pointer). navPatchNativeProfile rebuilt RoleBasedNativeHomePages the same way", "file": "`mdl/backend/modelsdk/navigation_write.go` (`navPatchWebProfile`, `navPatchNativeProfile`, new `navMergeRoleHomes`)", "insight": "Half of one writer was guard-don't-drop and half was not, and the docs said both ('fully replaces' and 'omitting the block leaves it alone'). describe -> exec could never show it because describe states every clause; only a hand-written partial statement does. Role homes are merged per role (stated role replaces, unmentioned role kept), so MDL can no longer REMOVE a role home page — there is no clearing syntax for home/login/not-found. Patchers now copy the bson.D first: navSetField writes in place, and a test that reused its input doc only passed because an appended field used to force a reallocation", "refs": ["mendixlabs/mxcli#1522", "ako/mxcli#1404"], "ce": ["CE0527"]} diff --git a/.claude/skills/mendix/choose-edit-mode/SKILL.md b/.claude/skills/mendix/choose-edit-mode/SKILL.md index dc107ecd3f..c23e67d687 100644 --- a/.claude/skills/mendix/choose-edit-mode/SKILL.md +++ b/.claude/skills/mendix/choose-edit-mode/SKILL.md @@ -12,8 +12,8 @@ description: "Decide how to change a document that already exists: ALTER, descri |---|---| | 30-49 | The rule | | 50-78 | Scripts are `mdl 1` — upgrade a headerless file before you edit it | -| 79-94 | Which document types have `alter` | -| 95-112 | Types with no `alter`: microflows, nanoflows, menus, Java actions | +| 79-95 | Which document types have `alter` | +| 96-113 | Types with no `alter`: microflows, nanoflows, menus, Java actions | # Choose the Edit Mode by Who Owns the Document @@ -90,7 +90,8 @@ change over. | Settings, security | `alter settings`, `alter app security`, `grant` / `revoke` | | Many pages at once | `update widgets … where …` (see `bulk-widget-updates`) | | **Microflow, nanoflow** | **none yet** | -| Menu, navigation profile, Java action | none (`alter navigation` replaces the whole profile) | +| Navigation profile | `create or modify navigation` with only the clauses to change — an omitted clause keeps its stored value; a `{ }` menu block replaces the whole menu | +| Menu, Java action | none | ## Types with no `alter`: microflows, nanoflows, menus, Java actions diff --git a/.claude/skills/mendix/manage-navigation/SKILL.md b/.claude/skills/mendix/manage-navigation/SKILL.md index dc1794cb0d..3fdf3dde0f 100644 --- a/.claude/skills/mendix/manage-navigation/SKILL.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -13,7 +13,7 @@ description: "Inspect and change navigation profiles in MDL — home pages, menu | 31-40 | When to Use This Skill | | 41-59 | Navigation Concepts | | 60-77 | Show Commands (Read-Only) | -| 78-358 | CREATE OR MODIFY NAVIGATION (Full Replacement) | +| 78-358 | CREATE OR MODIFY NAVIGATION | | 359-380 | Round-Trip Workflow | | 381-402 | Catalog Queries | | 403-452 | Common Patterns | @@ -75,9 +75,9 @@ list navigation menu; -- all profiles list navigation homes; ``` -## CREATE OR MODIFY NAVIGATION (Full Replacement) +## CREATE OR MODIFY NAVIGATION -This command fully replaces a navigation profile's configuration. All clauses are optional — omitted clauses clear that section. The output from `describe navigation` can be pasted back directly. +All clauses are optional, and **an omitted clause keeps the stored value** — a statement with only a `{ … }` menu keeps the home page, role home pages, login page and not-found page (mendixlabs/mxcli#1522; before that fix they were cleared, and the missing home page failed the build with CE0527). A stated clause replaces what it names: `{ … }` the whole menu, `sync ( … )` the whole list, `home page X for R` role R's home page only. There is no clause that clears a home, login or not-found page. The output from `describe navigation` can be pasted back directly. ### Basic: Set Home and Login Page diff --git a/.claude/skills/mendix/migrate-design-prototype/SKILL.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md index 5b09bc6970..c47943412a 100644 --- a/.claude/skills/mendix/migrate-design-prototype/SKILL.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -16,14 +16,14 @@ description: "Reproduce a Claude Design prototype or design handoff (HTML/CSS, . | 123-213 | ① Build the Theme — `mxcli theme create --from` | | 214-281 | ② Rebuild Components — Atlas block/class first, custom class only for identity | | 282-389 | Component → Mendix widget map | -| 390-485 | The App Shell: Navigation & Layout (built once, not per page) | -| 486-572 | ③ Apply Classes in Pages (MDL) | -| 573-586 | ④ Build & Preview | -| 587-594 | ⑤ Verify Against the Prototype | -| 595-631 | Never put a grid on a Mendix widget's own class | -| 632-652 | Gotchas (learned building this app) | -| 653-704 | MDL gotchas that block a faithful build | -| 705-723 | Checklist | +| 390-486 | The App Shell: Navigation & Layout (built once, not per page) | +| 487-573 | ③ Apply Classes in Pages (MDL) | +| 574-587 | ④ Build & Preview | +| 588-595 | ⑤ Verify Against the Prototype | +| 596-632 | Never put a grid on a Mendix widget's own class | +| 633-653 | Gotchas (learned building this app) | +| 654-705 | MDL gotchas that block a faithful build | +| 706-724 | Checklist | # Migrate a Claude Design Prototype into a Mendix App (Theme + Pages) @@ -410,8 +410,9 @@ mxcli -p baedemo.mpr -c "LIST NAVIGATION" # profiles, home page, it mxcli -p baedemo.mpr -c "LIST NAVIGATION MENU Responsive" # the menu tree ``` -Add or reorder items with `CREATE OR MODIFY NAVIGATION …` (full-replacement — dump -the current profile first with `DESCRIBE NAVIGATION `, edit, re-apply). See +Add or reorder items with `CREATE OR MODIFY NAVIGATION { … }` (the `{ }` block replaces +the whole menu, so dump the current profile first with `DESCRIBE NAVIGATION `, edit, +re-apply; omitted clauses such as the home page keep their stored value). See `manage-navigation` for the item syntax, home/login pages, and role-based homes. ### Style the shell to match the design diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index c6d0cef2f1..753e575a41 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -374,6 +374,13 @@ DISCONNECT;`, MENU 'Group' [( Icon: Module.IconCollection.Name )] { ... } }]; +-- Every clause is optional, and an OMITTED clause leaves the stored value +-- alone: a statement that states only a { } menu keeps the home page, the +-- role home pages, the login page and the not-found page (mendixlabs/mxcli#1522). +-- A stated clause replaces what it names: { } the whole menu, SYNC ( ) the +-- whole list, HOME ... FOR R the home page of role R only (other roles keep +-- theirs). There is no clause that clears a home, login or not-found page. +-- -- The menu items are the profile's CHILDREN, in { } after its clauses, like a -- page's widgets: no ; between them, since a child ends in ) or }. An item's -- action is OnClick: in the words a page action uses. The old spelling, diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index d8d45c7441..1d6998bcd0 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -283,7 +283,7 @@ END WORKFLOW; | List menu tree | `LIST NAVIGATION MENU [Profile];` | Menu tree for profile or all | | List home pages | `LIST NAVIGATION HOMES;` | Home page assignments across profiles | | Describe navigation | `DESCRIBE NAVIGATION [Profile];` | Full MDL output (round-trippable) | -| Create/replace navigation | `CREATE OR REPLACE NAVIGATION Profile ...;` | Full replacement of profile | +| Create/replace navigation | `CREATE OR REPLACE NAVIGATION Profile ...;` | Sets the stated clauses; an omitted clause keeps its stored value, a `{ }` menu replaces the whole menu | **Navigation Example:** ```sql diff --git a/docs-site/src/language/navigation-profiles.md b/docs-site/src/language/navigation-profiles.md index 5426273665..a6abd284e2 100644 --- a/docs-site/src/language/navigation-profiles.md +++ b/docs-site/src/language/navigation-profiles.md @@ -19,7 +19,11 @@ does not have it yet. The offline kinds are the online names plus `Offline`. ## CREATE OR REPLACE NAVIGATION -Replaces an entire navigation profile: +Sets the clauses it states on a navigation profile. Every clause is optional, +and an omitted clause keeps the stored value: a statement with only a menu +keeps the home page, role home pages, login page and not-found page. The +`{ }` block replaces the whole menu, and `HOME … FOR ` replaces the +home page of that role only. ```sql CREATE OR REPLACE NAVIGATION diff --git a/docs-site/src/reference/navigation/alter-navigation.md b/docs-site/src/reference/navigation/alter-navigation.md index 9049dd65a3..d5de0d5bd6 100644 --- a/docs-site/src/reference/navigation/alter-navigation.md +++ b/docs-site/src/reference/navigation/alter-navigation.md @@ -22,7 +22,9 @@ CREATE OR REPLACE NAVIGATION profile Creates or replaces a navigation profile. Each profile defines the home page, optional role-specific home pages, optional login page, optional 404 page, and a hierarchical menu structure. -The statement fully replaces the existing profile configuration. To modify only part of a profile's navigation, use DESCRIBE NAVIGATION to export the current configuration, edit the MDL, and re-execute. +Every clause is optional, and a clause the statement omits leaves the stored value alone. A statement that states only a menu keeps the profile's home page, role-based home pages, login page and not-found page. A clause that is stated replaces what it names: the `{ }` block replaces the whole menu, the `SYNC ( )` block the whole sync list, and `HOME … FOR Role` the home page of that role only, so other roles keep theirs. MDL has no clause that clears a home page, a login page or a not-found page. + +The output of DESCRIBE NAVIGATION states every clause the profile has, so it can be edited and re-executed as it is. ### Profile Types @@ -59,7 +61,7 @@ The old spelling — `MENU ( MENU ITEM 'Home' PAGE M.Home; … )` — still pars `PhoneOffline` or `NativePhone`. `HOME PAGE module.PageName` -: The default home page for the profile. Required. The page must already exist. +: The default home page for the profile. The page must already exist. Omitting the clause keeps the stored home page. A web profile without a home page fails the build with CE0527. `HOME MICROFLOW module.Flow` / `HOME NANOFLOW module.Flow` : A flow run as the home instead of a page: a microflow on a web profile, a @@ -68,15 +70,15 @@ The old spelling — `MENU ( MENU ITEM 'Home' PAGE M.Home; … )` — still pars nanoflow home, still runs with a warning). `HOME PAGE module.PageName FOR UserRole` -: Optional role-specific home page. Users with this role see a different home page than the default. Multiple role-specific home pages can be specified. +: Optional role-specific home page. Users with this role see a different home page than the default. Multiple role-specific home pages can be specified. Each one replaces the stored home page of its role only, and a role the statement does not mention keeps its home page. The role is a **user role**, written as a bare name (`FOR Administrator`). User roles are project-level and have no module part. A module-qualified name here — a module role, which often shares the name — produces a project Mendix cannot load: `StorageLoadException: … is not a valid UserRoleIdentifier`, raised before checking runs. `mxcli check --references` refuses it. `LOGIN PAGE module.PageName` -: Optional custom login page. If omitted, the default system login page is used. +: Optional custom login page. A profile with no login page uses the default system login page. Omitting the clause keeps the stored login page. `NOT FOUND PAGE module.PageName` -: Optional custom 404 page shown when a requested page is not found. +: Optional custom 404 page shown when a requested page is not found. Omitting the clause keeps the stored page. `{ menu_items }` : Optional menu structure. Contains `MENU ITEM` and nested `MENU` entries. An empty `{ }` clears the menu; omitting the block leaves it alone. diff --git a/mdl-examples/bug-tests/1522-navigation-omitted-clause-keeps-stored.mdl b/mdl-examples/bug-tests/1522-navigation-omitted-clause-keeps-stored.mdl new file mode 100644 index 0000000000..7d37725d67 --- /dev/null +++ b/mdl-examples/bug-tests/1522-navigation-omitted-clause-keeps-stored.mdl @@ -0,0 +1,47 @@ +mdl 1; +-- ============================================================================ +-- Bug #1522 (part b) — a `create or modify navigation` that states only a menu +-- cleared the profile's home page, role home pages, login page and not-found +-- page, so the next build failed with CE0527 "Select a default home page for +-- this profile". +-- +-- The menu block, the sync block, `on sync error` and `progressive web app` +-- already kept their stored value when omitted; the writer wrote the other four +-- unconditionally. An omitted clause now keeps the stored value for all of +-- them, and `home page … for ` replaces that role's home page only. +-- +-- mxcli exec this-file -p app.mpr ; mx check app.mpr +-- before: the second statement leaves `describe navigation Responsive` +-- without `home page`; mx check: CE0527 (1 error) +-- after: `home page Issue1522.Home` is still described; mx check: 0 errors +-- ============================================================================ + +create module Issue1522; + +create page Issue1522.Home ( + Title: 'Home', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext txtHome (Content: 'Home') +}; + +create page Issue1522.Orders ( + Title: 'Orders', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext txtOrders (Content: 'Orders') +}; + +create or modify navigation Responsive + home page Issue1522.Home +{ + menu item 'Home' ( OnClick: show page Issue1522.Home, Icon: Atlas_Core.Atlas.home ) +}; + +-- States only the menu: the home page above must survive it. +create or modify navigation Responsive +{ + menu item 'Orders' ( OnClick: show page Issue1522.Orders, Icon: Atlas_Core.Atlas."list-bullets" ) +}; + +describe navigation Responsive; diff --git a/mdl/backend/modelsdk/navigation_omitted_clause_test.go b/mdl/backend/modelsdk/navigation_omitted_clause_test.go new file mode 100644 index 0000000000..548948b2ba --- /dev/null +++ b/mdl/backend/modelsdk/navigation_omitted_clause_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +// mendixlabs/mxcli#1522: an omitted clause in `create or modify navigation` +// keeps the stored value, as the menu block, the sync block, `on sync error` +// and `progressive web app` already did. The writer overwrote the home page, +// the role-based home pages, the login page and the fallback page on every +// statement, so `create or modify navigation Responsive { menu item … }` left +// a profile with no home page: CE0527 at the next build. +func TestUpdateNavigationProfile_OmittedClausesKeepStored(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + nav, err := b.GetNavigation() + if err != nil || nav == nil { + t.Fatalf("GetNavigation: %v", err) + } + prof := "" + for _, p := range nav.Profiles { + if !p.IsNative { + prof = p.Name + break + } + } + if prof == "" { + t.Fatal("fixture has no web profile") + } + + full := types.NavigationProfileSpec{ + HomePages: []types.NavHomePageSpec{ + {IsPage: true, Target: "MyFirstModule.Home"}, + {IsPage: true, Target: "MyFirstModule.AdminHome", ForRole: "Administrator"}, + {IsPage: false, Target: "MyFirstModule.UserHome", ForRole: "User"}, + }, + LoginPage: "MyFirstModule.Login", + NotFoundPage: "MyFirstModule.NotFound", + } + if err := b.UpdateNavigationProfile(nav.ID, prof, full); err != nil { + t.Fatalf("first update: %v", err) + } + // The issue's second statement: a menu and nothing else. + if err := b.UpdateNavigationProfile(nav.ID, prof, types.NavigationProfileSpec{ + HasMenu: true, + MenuItems: []types.NavMenuItemSpec{{Caption: "B", Page: "MyFirstModule.B"}}, + }); err != nil { + t.Fatalf("menu-only update: %v", err) + } + + p := reloadProfile(t, proj, prof) + if p.HomePage == nil || p.HomePage.Page != "MyFirstModule.Home" { + t.Errorf("home page not kept: %+v", p.HomePage) + } + if p.LoginPage != "MyFirstModule.Login" { + t.Errorf("login page not kept: %q", p.LoginPage) + } + if p.NotFoundPage != "MyFirstModule.NotFound" { + t.Errorf("not-found page not kept: %q", p.NotFoundPage) + } + if len(p.RoleBasedHomePages) != 2 { + t.Fatalf("role-based home pages = %d, want 2 kept", len(p.RoleBasedHomePages)) + } + if len(p.MenuItems) != 1 || p.MenuItems[0].Caption != "B" { + t.Errorf("menu not written: %+v", p.MenuItems) + } +} + +func reloadProfile(t *testing.T, proj, name string) *types.NavigationProfile { + t.Helper() + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + nav, err := b.GetNavigation() + if err != nil { + t.Fatalf("GetNavigation: %v", err) + } + for _, p := range nav.Profiles { + if p.Name == name { + return p + } + } + t.Fatalf("profile %q gone", name) + return nil +} + +// A stated clause still writes: the default home page and one role's home page +// replace what is stored for them, and a role the statement does not mention +// keeps its home page. +func TestNavPatchWebProfile_StatedClausesReplace(t *testing.T) { + stored := bson.D{ + {Key: "$Type", Value: "Navigation$NavigationProfile"}, + {Key: "HomePage", Value: navHomePageBson(true, "M.OldHome", "")}, + {Key: "HomeItems", Value: bson.A{navMarkerHomeItems, + navHomePageBson(true, "M.AdminOld", "Administrator"), + navHomePageBson(true, "M.UserHome", "User"), + }}, + {Key: "LoginPageSettings", Value: navFormSettingsBson("M.OldLogin")}, + {Key: "NotFoundHomepage", Value: nil}, + } + out, err := navPatchWebProfile(stored, types.NavigationProfileSpec{ + HomePages: []types.NavHomePageSpec{ + {IsPage: true, Target: "M.NewHome"}, + {IsPage: true, Target: "M.AdminNew", ForRole: "administrator"}, + {IsPage: true, Target: "M.Guest", ForRole: "Guest"}, + }, + LoginPage: "M.NewLogin", + NotFoundPage: "M.Missing", + }) + if err != nil { + t.Fatal(err) + } + if got := navGetString(navGetDoc(out, "HomePage"), "Page"); got != "M.NewHome" { + t.Errorf("home page = %q", got) + } + if got := navGetString(navGetDoc(out, "LoginPageSettings"), "Form"); got != "M.NewLogin" { + t.Errorf("login page = %q", got) + } + if got := navGetString(navGetDoc(out, "NotFoundHomepage"), "Page"); got != "M.Missing" { + t.Errorf("not-found page = %q", got) + } + items := navGetArray(out, "HomeItems") + if len(items) != 4 || items[0] != navMarkerHomeItems { + t.Fatalf("HomeItems = %v, want marker + 3", items) + } + byRole := map[string]string{} + for _, it := range items[1:] { + d := it.(bson.D) + byRole[navGetString(d, "UserRole")] = navGetString(d, "Page") + } + if byRole["Administrator"] != "" || byRole["administrator"] != "M.AdminNew" { + t.Errorf("stated role not replaced in place of the stored one: %v", byRole) + } + if byRole["User"] != "M.UserHome" { + t.Errorf("unmentioned role's home page not kept: %v", byRole) + } + if byRole["Guest"] != "M.Guest" { + t.Errorf("new role's home page not added: %v", byRole) + } +} + +// The native writer has the same shape: it kept an omitted default home page +// but rebuilt RoleBasedNativeHomePages from the statement alone. +func TestNavPatchNativeProfile_OmittedRoleHomesKeepStored(t *testing.T) { + roleHome := bson.D{ + {Key: "$ID", Value: navID()}, + {Key: "$Type", Value: "Navigation$RoleBasedNativeHomePage"}, + {Key: "UserRole", Value: "User"}, + {Key: "HomePagePage", Value: "M.UserHome"}, + {Key: "HomePageNanoflow", Value: ""}, + } + doc := bson.D{ + {Key: "$Type", Value: "Navigation$NativeNavigationProfile"}, + {Key: "Name", Value: "NativePhone"}, + {Key: "RoleBasedNativeHomePages", Value: bson.A{navMarkerHomeItems, roleHome}}, + } + out := navPatchNativeProfile(doc, types.NavigationProfileSpec{ + HomePages: []types.NavHomePageSpec{{IsPage: true, Target: "M.Home"}}, + }) + items := navGetArray(out, "RoleBasedNativeHomePages") + if len(items) != 2 || navGetString(items[1].(bson.D), "HomePagePage") != "M.UserHome" { + t.Errorf("role-based native home page not kept: %v", items) + } +} diff --git a/mdl/backend/modelsdk/navigation_write.go b/mdl/backend/modelsdk/navigation_write.go index b45e380223..57d90447a5 100644 --- a/mdl/backend/modelsdk/navigation_write.go +++ b/mdl/backend/modelsdk/navigation_write.go @@ -141,6 +141,9 @@ func navID() any { return bsonutil.NewIDBsonBinary() } // --- profile patchers --- func navPatchWebProfile(doc bson.D, spec types.NavigationProfileSpec) (bson.D, error) { + // Patch a copy: navSetField writes in place, and a caller's stored profile + // must not change under it when no field is appended. + doc = append(bson.D(nil), doc...) var defaultHome *types.NavHomePageSpec var roleHomes []types.NavHomePageSpec for _, hp := range spec.HomePages { @@ -152,19 +155,28 @@ func navPatchWebProfile(doc bson.D, spec types.NavigationProfileSpec) (bson.D, e } } + // An omitted clause keeps what is stored (mendixlabs/mxcli#1522), as the + // menu block, the sync block, ON SYNC ERROR and PROGRESSIVE WEB APP already + // did. These four were written unconditionally, so a statement that stated + // only a menu cleared the home page -- CE0527 at the next build -- along + // with the role-based home pages, the login page and the fallback page. + // MDL has no clause that clears one of them; describe states every one the + // profile has, so describe -> exec is unaffected. if defaultHome != nil { doc = navSetField(doc, "HomePage", navHomePageBson(defaultHome.IsPage, defaultHome.Target, "")) - } else { - doc = navSetField(doc, "HomePage", navHomePageBson(false, "", "")) } - homeItems := bson.A{navMarkerHomeItems} - for _, rh := range roleHomes { - homeItems = append(homeItems, navHomePageBson(rh.IsPage, rh.Target, rh.ForRole)) + if len(roleHomes) > 0 { + var built []bson.D + for _, rh := range roleHomes { + built = append(built, navHomePageBson(rh.IsPage, rh.Target, rh.ForRole)) + } + doc = navSetField(doc, "HomeItems", navMergeRoleHomes(navGetArray(doc, "HomeItems"), built)) } - doc = navSetField(doc, "HomeItems", homeItems) - doc = navSetField(doc, "LoginPageSettings", navFormSettingsBson(spec.LoginPage)) + if spec.LoginPage != "" { + doc = navSetField(doc, "LoginPageSettings", navFormSettingsBson(spec.LoginPage)) + } if spec.NotFoundPage != "" { doc = navSetField(doc, "NotFoundHomepage", bson.D{ @@ -186,8 +198,6 @@ func navPatchWebProfile(doc bson.D, spec types.NavigationProfileSpec) (bson.D, e {Key: "Microflow", Value: ""}, {Key: "Page", Value: spec.NotFoundPage}, }) - } else { - doc = navSetField(doc, "NotFoundHomepage", nil) } if spec.HasMenu { @@ -271,6 +281,9 @@ func navGetDoc(doc bson.D, key string) bson.D { } func navPatchNativeProfile(doc bson.D, spec types.NavigationProfileSpec) bson.D { + // Patch a copy: navSetField writes in place, and a caller's stored profile + // must not change under it when no field is appended. + doc = append(bson.D(nil), doc...) var defaultHome *types.NavHomePageSpec var roleHomes []types.NavHomePageSpec for _, hp := range spec.HomePages { @@ -292,18 +305,23 @@ func navPatchNativeProfile(doc bson.D, spec types.NavigationProfileSpec) bson.D }) } - roleItems := bson.A{navMarkerHomeItems} - for _, rh := range roleHomes { - page, nf := navSplitTarget(rh.IsPage, rh.Target) - roleItems = append(roleItems, bson.D{ - {Key: "$ID", Value: navID()}, - {Key: "$Type", Value: "Navigation$RoleBasedNativeHomePage"}, - {Key: "UserRole", Value: rh.ForRole}, - {Key: "HomePagePage", Value: page}, - {Key: "HomePageNanoflow", Value: nf}, - }) + // Same rule as the web writer: a role the statement does not mention keeps + // its home page (mendixlabs/mxcli#1522). + if len(roleHomes) > 0 { + var built []bson.D + for _, rh := range roleHomes { + page, nf := navSplitTarget(rh.IsPage, rh.Target) + built = append(built, bson.D{ + {Key: "$ID", Value: navID()}, + {Key: "$Type", Value: "Navigation$RoleBasedNativeHomePage"}, + {Key: "UserRole", Value: rh.ForRole}, + {Key: "HomePagePage", Value: page}, + {Key: "HomePageNanoflow", Value: nf}, + }) + } + doc = navSetField(doc, "RoleBasedNativeHomePages", + navMergeRoleHomes(navGetArray(doc, "RoleBasedNativeHomePages"), built)) } - doc = navSetField(doc, "RoleBasedNativeHomePages", roleItems) // A native profile stores its offline configs as a web profile does. The // executor refuses the clauses this writer cannot apply to a native @@ -316,6 +334,42 @@ func navPatchNativeProfile(doc bson.D, spec types.NavigationProfileSpec) bson.D return doc } +// navMergeRoleHomes is a profile's role-based home pages after a statement that +// states some of them: a stated role's element replaces the stored one in its +// position, a role the statement does not mention is kept verbatim, and a new +// role is appended. Roles match case-insensitively, as profile names do. +// +// So MDL can add or change a role's home page but not remove one; describe -> +// exec, which states every stored role, is unaffected (mendixlabs/mxcli#1522). +func navMergeRoleHomes(stored bson.A, built []bson.D) bson.A { + out := bson.A{navMarkerHomeItems} + used := make([]bool, len(built)) + for _, item := range stored { + d, ok := item.(bson.D) + if !ok { + continue // the leading marker + } + role := navGetString(d, "UserRole") + replaced := false + for i, b := range built { + if !used[i] && strings.EqualFold(navGetString(b, "UserRole"), role) { + out = append(out, b) + used[i], replaced = true, true + break + } + } + if !replaced { + out = append(out, d) + } + } + for i, b := range built { + if !used[i] { + out = append(out, b) + } + } + return out +} + func navSplitTarget(isPage bool, target string) (page, nanoflow string) { if isPage { return target, "" diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index a9d20875e3..d41bf366d3 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -15,7 +15,9 @@ import ( ) // execAlterNavigation handles CREATE [OR REPLACE] NAVIGATION command. -// It fully replaces the profile's home pages, login page, not-found page, and menu tree. +// A clause the statement states replaces what it names; an omitted clause keeps +// the stored value (mendixlabs/mxcli#1522) -- the writer's rule, see +// navPatchWebProfile. func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() From 30261876ec50a3a8f92e7f92ee28b204b3d3c921 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:43:27 +0000 Subject: [PATCH 10/25] fix(widgets): drop-down filter keeps its attribute (mendixlabs/mxcli#1519) `dropdownfilter f (attributes: [Status])` stored attrChoice "auto" with an empty `attr`. check, exec and mx check stayed clean, describe printed a bare `dropdownfilter`, and in a gallery's filter bar (no column to inherit an attribute from) the filter failed at runtime with "Unable to get filter store". Cause: dropdownfilter.def.json mapped `Attributes:` onto `attributes` and `defaultFilter`, the text filter's keys. The DataWidgets 3.x drop-down filter (3.4.0 in 11.15; same keys in the 11.6 template) has one attribute slot, `attr`, which its editorConfig shows only under attrChoice "linked". Every builder Set* operation skips a key the template does not declare, so the value was dropped without a word. Fix: - def: a new `attribute` mode (condition `hasAttributes`) writes attrChoice=linked and the attribute on `attr`; a bare filter keeps "auto". `Attributes:` feeding the single-attribute op resolves one attribute and refuses a list of more (exec, and MDL-WIDGET47 at check). - engine: an AUTHORED value mapped to a key the loaded template does not declare now fails the statement (optional backend.WidgetPropertyDeclarer, implemented by widgetobj.Builder; the MCP builder cannot answer and is unchanged). Mapping constants are exempt. - check: widgetValueMap read the constants of every mode, last one winning, so the new `attr` was reported hidden under the default mode's "auto" and the association mode's baseType "ref". Inactive modes' constants are now ignored. - describe: reads `attr` back as `Attributes: [A]` under attrChoice linked. Measured (fixture copies, create + mx check): 11.6.6 and 11.15.0 both store attrChoice=linked, attr=Tmp.Item.Status for the column and the gallery filter, 0 errors, describe prints `(Attributes: [Status])`. Before: auto, empty attr, 0 errors, bare describe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...er-attribute-mapped-to-undeclared-key.json | 1 + .../mendix/create-page/reference/widgets.md | 6 + .../1519-dropdownfilter-attribute.mdl | 31 ++ mdl/backend/mutation.go | 13 + mdl/backend/widgetobj/builder.go | 12 +- mdl/executor/cmd_pages_describe_parse.go | 10 + mdl/executor/validate_widgets.go | 32 +- .../widget_dropdownfilter_attr_test.go | 285 ++++++++++++++++++ mdl/executor/widget_engine.go | 95 +++++- mdl/executor/widget_registry.go | 7 +- .../definitions/dropdownfilter.def.json | 23 +- 11 files changed, 497 insertions(+), 18 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-dropdownfilter-attribute-mapped-to-undeclared-key.json create mode 100644 mdl-examples/bug-tests/1519-dropdownfilter-attribute.mdl create mode 100644 mdl/executor/widget_dropdownfilter_attr_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-dropdownfilter-attribute-mapped-to-undeclared-key.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-dropdownfilter-attribute-mapped-to-undeclared-key.json new file mode 100644 index 0000000000..c6c59168d1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-dropdownfilter-attribute-mapped-to-undeclared-key.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`dropdownfilter f (attributes: [Status])` stored attrChoice \"auto\" and an empty `attr`, with check, exec and mx check all clean; describe printed a bare `dropdownfilter`, and in a gallery's filter bar (no column to inherit from) the filter failed at runtime with \"Unable to get filter store\" (mendixlabs/mxcli#1519 item 2)", "cause": "dropdownfilter.def.json mapped `Attributes:` onto `attributes`/`defaultFilter` — the TEXT filter's keys. DataWidgets 3.x DatagridDropdownFilter (3.4.0 in 11.15, same keys in the 11.6 template) has one attribute slot, `attr`, shown only when attrChoice is \"linked\". Every widgetobj.Builder Set* operation silently skips a key the template does not declare, so the value vanished", "file": "`sdk/widgets/definitions/dropdownfilter.def.json` (new `attribute` mode on condition `hasAttributes`: attrChoice=linked + attr), `mdl/executor/widget_engine.go` (`refuseUndeclaredMappedKey` via the optional `backend.WidgetPropertyDeclarer`; `Attributes`→`attribute` single-slot resolution + refusal; `selectMode`), `mdl/executor/validate_widgets.go` (`widgetValueMap` ignores inactive modes' constants; MDL-WIDGET47), `mdl/executor/cmd_pages_describe_parse.go` (reads `attr` back as `Attributes: [A]`)", "insight": "**A hand-written .def.json can map to a key the widget never had, and nothing notices** — the builder's Set* operations are no-ops for an unknown key. The guard is now in the engine: an AUTHORED value routed to a key the loaded template does not declare fails the statement (constants are exempt — an older widget version lacking a key is not the script's fault; the MCP builder, which cannot answer, does not implement the check). Fixing the mapping alone exposed a second defect: `widgetValueMap` read the constants of EVERY mode, last one winning, so check called the new `attr` hidden under the default mode's attrChoice \"auto\" and the association mode's baseType \"ref\" — visible only with a project, because the visibility rules are lifted from the installed .mpk, so a no-project check test passed against the bug. Test the value map directly. Note `modelsdk/widgets/definitions/` holds a second, unused-in-production copy of this def (already attr+linked); `sdk/widgets/definitions/` is the one the registry embeds.", "refs": ["mendixlabs/mxcli#1519", "ako/mxcli#1401"], "rules": ["MDL-WIDGET47", "MDL-WIDGET10"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 41fa1ede1e..74834c9517 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -701,6 +701,12 @@ datefilter datefilter (attributes: [Module.Entity.CreateDate]) dropdownfilter statusFilter (attributes: [Module.Entity.Status]) ``` +It filters on **one** attribute (an enumeration or a Boolean), stored as the widget's +custom attribute (Studio Pro's "Attribute config: Custom"); listing two is refused +(MDL-WIDGET47) — use a `textfilter` for several. A bare `dropdownfilter f` takes the +attribute of the Data grid 2 column it sits in ("Auto"), so in a **gallery's** +`filter { … }` bar, which has no column, the attribute must be named. + Filter by an **association** instead of an attribute — the options are the associated objects. Giving the filter a `datasource:` (the OPTION list) selects this mode; all three parts are required: diff --git a/mdl-examples/bug-tests/1519-dropdownfilter-attribute.mdl b/mdl-examples/bug-tests/1519-dropdownfilter-attribute.mdl new file mode 100644 index 0000000000..696b19434e --- /dev/null +++ b/mdl-examples/bug-tests/1519-dropdownfilter-attribute.mdl @@ -0,0 +1,31 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1519 (item 2) — a drop-down filter loses its attribute +-- ============================================================================ +-- +-- `dropdownfilter f (attributes: [Status])` stored attrChoice "auto" with an +-- empty `attr`: check, exec and mx check were clean, describe printed a bare +-- `dropdownfilter`, and in a gallery (no column to inherit an attribute from) +-- the filter failed at runtime with "Unable to get filter store". +-- +-- Expected now: both filters store attrChoice "linked" + attr = Bug1519.Item.Status, +-- `mx check` reports 0 errors, and DESCRIBE PAGE prints +-- `dropdownfilter … (Attributes: [Status])` for each. Listing two attributes is +-- refused (MDL-WIDGET47): the widget has one attribute slot. + +create module Bug1519; +create enumeration Bug1519.ItemStatus ( Open 'Open', Closed 'Closed' ); +create persistent entity Bug1519.Item ( Name: String(100), Status: Enum Bug1519.ItemStatus ); + +create page Bug1519.Item_Overview ( Title: 'Items', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg ( DataSource: database from Bug1519.Item ) { + column (Attribute: Name, Caption: 'Name') { textfilter tfName } + column (Attribute: Status, Caption: 'Status') { dropdownfilter ddfColumn (attributes: [Status]) } + } + gallery gal ( DataSource: database from Bug1519.Item ) { + filter { dropdownfilter ddfGallery (attributes: [Status]) } + template { dynamictext txtName (Content: 'item') } + } +}; + +describe page Bug1519.Item_Overview; diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 36b6affa8e..bb92e08e12 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -446,6 +446,19 @@ type WidgetObjectBuilder interface { Finalize(id model.ID, name string, label string, editable string) *pages.CustomWidget } +// WidgetPropertyDeclarer is implemented by a WidgetObjectBuilder that holds the +// widget's full property set, and so can say authoritatively whether a key +// exists. Every Set* operation on a key the widget does not declare is a no-op, +// so a definition mapping an authored value to such a key drops the value with +// no error (mendixlabs/mxcli#1519: a drop-down filter's attribute, routed to the +// text filter's `attributes`). The engine asks this before applying an authored +// value and refuses the statement instead. Optional: a builder that only +// records property sets (the Studio Pro MCP backend) cannot answer and does not +// implement it. +type WidgetPropertyDeclarer interface { + DeclaresProperty(propertyKey string) bool +} + // ObjectListItemSpec describes one item of an object-list property (e.g. one // Accordion group, one PopupMenu basicItem, one Maps marker). The backend // applies these specs to the template's nested PropertyTypeIDs to produce diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index a9b3d84958..be4d370a95 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -53,7 +53,17 @@ type Builder struct { objectTypeID string } -var _ backend.WidgetObjectBuilder = (*Builder)(nil) +var ( + _ backend.WidgetObjectBuilder = (*Builder)(nil) + _ backend.WidgetPropertyDeclarer = (*Builder)(nil) +) + +// DeclaresProperty reports whether the loaded template declares propertyKey — +// the set every Set* operation silently skips a missing key against. +func (ob *Builder) DeclaresProperty(propertyKey string) bool { + _, ok := ob.propertyTypeIDs[propertyKey] + return ok +} // New constructs a Builder for a loaded template and registers the engine's child // serializer for this build. diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 54c16ad676..15eacb00ee 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -541,6 +541,16 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s if widget.RenderMode == "textfilter" || widget.RenderMode == "numberfilter" || widget.RenderMode == "dropdownfilter" || widget.RenderMode == "datefilter" { widget.FilterAttributes = extractFilterAttributes(ctx, w) widget.FilterExpression = extractFilterExpression(ctx, w) + // The drop-down filter keeps its ONE custom attribute on `attr` + // (attrChoice "linked"), not in an `attributes` list. Read it back + // as the `attributes: [A]` it is written with, or a describe→exec + // cycle loses it (#1519). Under "auto" the column supplies it. + if widget.RenderMode == "dropdownfilter" && len(widget.FilterAttributes) == 0 && + extractCustomWidgetPropertyString(ctx, w, "attrChoice") == "linked" { + if attr := extractCustomWidgetPropertyAttributeRef(ctx, w, "attr"); attr != "" { + widget.FilterAttributes = []string{attr} + } + } } // For pluggable Image widget, extract image-specific properties if widget.RenderMode == "image" { diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index b9a232bc87..e62f53dfc6 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -266,6 +266,16 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validatePluggableTemplateParams(w, locationPrefix)...) } out = append(out, validateWidgetVisibility(w, registry, locationPrefix)...) + // #1519: several attributes for a widget with one attribute slot — the + // drop-down filter. exec refuses it; so does check. + if m, attrs, over := singleAttributeOverflow(lookupWidgetDef(w, registry), w); over { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET47", + Severity: linter.SeverityError, + Message: locationPrefix + ": " + singleAttributeOverflowMessage(w, m, attrs), + Suggestion: "A drop-down filter compares one attribute; list several only on a textfilter.", + }) + } // 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)...) @@ -641,8 +651,22 @@ func widgetValueMap(w *ast.WidgetV3, def *WidgetDefinition) (values map[string]s explicit = map[string]bool{} mappings := append([]PropertyMapping(nil), def.PropertyMappings...) - for _, m := range def.Modes { - mappings = append(mappings, m.PropertyMappings...) + // A mode the widget is NOT built in writes none of its constants, so they + // are not the widget's configuration. Reading them anyway let whichever mode + // came last decide: a drop-down filter given `attributes:` builds with + // attrChoice "linked" and baseType at its default, but the union read the + // default mode's "auto" and the association mode's "ref", and reported its + // `attr` as hidden twice over (#1519). What the script wrote still counts. + inactive := map[int]bool{} + active, _ := (&PluggableWidgetEngine{}).selectMode(def, w) + for i := range def.Modes { + mode := &def.Modes[i] + for _, m := range mode.PropertyMappings { + if active != nil && mode != active { + inactive[len(mappings)] = true + } + mappings = append(mappings, m) + } } // An MDL source keyword shared by SEVERAL properties does not name any one of // them. File Uploader 2.5.0 routes both `associatedFiles` and @@ -656,7 +680,7 @@ func widgetValueMap(w *ast.WidgetV3, def *WidgetDefinition) (values map[string]s sharedSource[m.Source]++ } } - for _, m := range mappings { + for i, m := range mappings { key := strings.ToLower(m.PropertyKey) val, set := "", false viaSharedSource := false @@ -683,6 +707,8 @@ func widgetValueMap(w *ast.WidgetV3, def *WidgetDefinition) (values map[string]s if !viaSharedSource { explicit[key] = true } + } else if inactive[i] { + continue // a constant of a mode the widget is not built in } else if m.Default != "" { val = m.Default } else if m.Operation == "primitive" && m.Value != "" { diff --git a/mdl/executor/widget_dropdownfilter_attr_test.go b/mdl/executor/widget_dropdownfilter_attr_test.go new file mode 100644 index 0000000000..04a5ce40d0 --- /dev/null +++ b/mdl/executor/widget_dropdownfilter_attr_test.go @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1519, item 2: +// +// dropdownfilter ddf (attributes: [Status]) +// +// passed check, exec and mx check, and stored attrChoice="auto" with an EMPTY +// `attr` — the attribute was gone. Describe printed a bare `dropdownfilter`, and +// in a gallery (no column to inherit an attribute from) the filter failed at +// runtime with "Unable to get filter store". +// +// The definition mapped `Attributes` onto `attributes`/`defaultFilter`, the text +// filter's keys. The DataWidgets drop-down filter (3.x; 3.4.0 in 11.15, the 11.6 +// template here) has neither: it filters on ONE attribute, `attr`, which its +// editorConfig shows only when attrChoice is "linked" (Custom). And the builder +// dropped the value for the undeclared key without a word. + +// dropdownFilterEngine builds inside a context over Tmp.Item (Name, Status) with +// the REAL drop-down filter template. +func dropdownFilterEngine(t *testing.T) *PluggableWidgetEngine { + t.Helper() + real := &modelsdkbackend.Backend{} + mod := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-tmp")}, Name: "Tmp"} + b := &mock.MockBackend{ + LoadWidgetTemplateFunc: real.LoadWidgetTemplate, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ + BaseElement: model.BaseElement{ID: model.ID("dm-tmp")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: model.ID("e-item")}, Name: "Item", + Attributes: []*domainmodel.Attribute{{Name: "Name"}, {Name: "Status"}}}, + }, + }}, nil + }, + } + pb := &pageBuilder{ + backend: b, + entityContext: "Tmp.Item", + paramEntityNames: map[string]string{}, + widgetScope: map[string]model.ID{}, + } + e := NewPluggableWidgetEngine(b, pb) + pb.pluggableEngine = e + return e +} + +func realDropdownFilterDef(t *testing.T) *WidgetDefinition { + t.Helper() + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + def, ok := reg.Get("DROPDOWNFILTER") + if !ok { + t.Fatal("no DROPDOWNFILTER definition") + } + return def +} + +// customWidgetValue returns the WidgetValue stored for a property key. +func customWidgetValue(t *testing.T, cw *pages.CustomWidget, key string) bson.D { + t.Helper() + entry, ok := cw.PropertyTypeIDMap[key] + if !ok { + t.Fatalf("template declares no property %q", key) + } + want := strings.ReplaceAll(entry.PropertyTypeID, "-", "") + for _, e := range cw.RawObject { + if e.Key != "Properties" { + continue + } + for _, item := range e.Value.(bson.A) { + prop, ok := item.(bson.D) + if !ok { + continue + } + var ptr string + var val bson.D + for _, f := range prop { + switch f.Key { + case "TypePointer": + switch b := f.Value.(type) { + case primitive.Binary: + ptr = types.BlobToUUID(b.Data) + case []byte: + ptr = types.BlobToUUID(b) + } + case "Value": + val, _ = f.Value.(bson.D) + } + } + if strings.ReplaceAll(ptr, "-", "") == want { + return val + } + } + } + t.Fatalf("property %q not found in the widget object", key) + return nil +} + +func storedAttribute(t *testing.T, cw *pages.CustomWidget, key string) string { + t.Helper() + ref, _ := bsonField(customWidgetValue(t, cw, key), "AttributeRef").(bson.D) + s, _ := bsonField(ref, "Attribute").(string) + return s +} + +func storedPrimitive(t *testing.T, cw *pages.CustomWidget, key string) string { + t.Helper() + s, _ := bsonField(customWidgetValue(t, cw, key), "PrimitiveValue").(string) + return s +} + +func buildDropdownFilter(t *testing.T, src string) (*pages.CustomWidget, error) { + t.Helper() + w := parseSnippetWidget(t, src) + widget, err := dropdownFilterEngine(t).Build(realDropdownFilterDef(t), w) + if err != nil { + return nil, err + } + return widget, nil +} + +// The reported case: the attribute lands on `attr`, and attrChoice selects it. +func TestDropdownFilter1519_AttributeIsStoredOnAttr(t *testing.T) { + cw, err := buildDropdownFilter(t, `dropdownfilter ddf (attributes: [Status])`) + if err != nil { + t.Fatalf("Build: %v", err) + } + if got := storedAttribute(t, cw, "attr"); got != "Tmp.Item.Status" { + t.Errorf("attr = %q, want Tmp.Item.Status — the filter has lost its attribute", got) + } + if got := storedPrimitive(t, cw, "attrChoice"); got != "linked" { + t.Errorf("attrChoice = %q, want linked — under \"auto\" the editorConfig hides `attr`", got) + } +} + +// CONTROL: a bare filter in a column inherits the column's attribute, which is +// what attrChoice "auto" means. It must stay that way. +func TestDropdownFilter1519_BareFilterStaysAuto(t *testing.T) { + cw, err := buildDropdownFilter(t, `dropdownfilter ddf`) + if err != nil { + t.Fatalf("Build: %v", err) + } + if got := storedPrimitive(t, cw, "attrChoice"); got != "auto" { + t.Errorf("attrChoice = %q, want auto for a filter given no attribute", got) + } + if got := storedAttribute(t, cw, "attr"); got != "" { + t.Errorf("attr = %q, want empty for a filter given no attribute", got) + } +} + +// The widget has ONE attribute slot. Two attributes cannot be stored, and +// keeping the first while discarding the second is the same silent drop. +func TestDropdownFilter1519_SeveralAttributesAreRefused(t *testing.T) { + _, err := buildDropdownFilter(t, `dropdownfilter ddf (attributes: [Status, Name])`) + if err == nil { + t.Fatal("two attributes on a drop-down filter were accepted; only one can be stored") + } + if !strings.Contains(err.Error(), "one attribute") { + t.Errorf("refusal does not say the widget takes one attribute: %v", err) + } +} + +// Check refuses what exec refuses, and reports nothing for the single-attribute +// form — in particular no MDL-WIDGET10 "attr is hidden when attrChoice is auto": +// the constants of a mode the widget is not built in are not its configuration. +func TestCheck1519_DropdownFilterAttributes(t *testing.T) { + rules := func(widget string) map[string]int { + got := map[string]int{} + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { gallery g (DataSource: database from M.E) { filter f { ` + + widget + ` } template t { dynamictext x (Content: 'x') } } };` + for _, v := range ValidateWidgetProperties(parseMDL(t, src), "") { + got[v.RuleID]++ + } + return got + } + if got := rules(`dropdownfilter ddf (attributes: [Status, Name])`); got["MDL-WIDGET47"] != 1 { + t.Errorf("two attributes: MDL-WIDGET47 reported %d times, want 1 (all: %v)", got["MDL-WIDGET47"], got) + } + got := rules(`dropdownfilter ddf (attributes: [Status])`) + if got["MDL-WIDGET47"] != 0 || got["MDL-WIDGET10"] != 0 { + t.Errorf("one attribute: unexpected diagnostics %v", got) + } +} + +// The builder half: a definition that routes an AUTHORED value to a key the +// widget does not declare must fail the statement, not drop the value. This is +// the shape the old dropdownfilter definition had. +func TestPluggableEngine1519_AuthoredValueForUndeclaredKeyIsRefused(t *testing.T) { + def := *realDropdownFilterDef(t) + def.Modes = nil + def.PropertyMappings = []PropertyMapping{ + {PropertyKey: "attributes", Source: "Attributes", Operation: "attributeObjects"}, + } + w := parseSnippetWidget(t, `dropdownfilter ddf (attributes: [Status])`) + _, err := dropdownFilterEngine(t).Build(&def, w) + if err == nil { + t.Fatal("a value for the undeclared key `attributes` was dropped without an error") + } + if !strings.Contains(err.Error(), "attributes") { + t.Errorf("refusal does not name the undeclared key: %v", err) + } +} + +// dropdownFilterRaw is a stored drop-down filter as describe reads it: attrChoice +// and attr resolved through the widget's own PropertyTypes. +func dropdownFilterRaw(attrChoice, attr string) map[string]any { + prop := func(id string, v map[string]any) map[string]any { + return map[string]any{"TypePointer": id, "Value": v} + } + return map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "Name": "ddf", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.web.datagriddropdownfilter.DatagridDropdownFilter", + "ObjectType": map[string]any{"PropertyTypes": []any{ + map[string]any{"$ID": "pt-choice", "PropertyKey": "attrChoice", + "ValueType": map[string]any{"Type": "Enumeration"}}, + map[string]any{"$ID": "pt-attr", "PropertyKey": "attr", + "ValueType": map[string]any{"Type": "Attribute"}}, + }}, + }, + "Object": map[string]any{"Properties": []any{ + prop("pt-choice", map[string]any{"PrimitiveValue": attrChoice}), + prop("pt-attr", map[string]any{"AttributeRef": map[string]any{"Attribute": attr}}), + }}, + } +} + +// Describe prints the custom attribute back in the form exec reads. +func TestDescribe1519_DropdownFilterPrintsItsAttribute(t *testing.T) { + got := parseRawWidget(nil, dropdownFilterRaw("linked", "Tmp.Item.Status")) + if len(got) != 1 || len(got[0].FilterAttributes) != 1 || got[0].FilterAttributes[0] != "Status" { + t.Fatalf("described %d widget(s), filter attributes %v; want one with [Status] — describe printed a bare dropdownfilter", + len(got), filterAttrsOf(got)) + } + // CONTROL: under "auto" the column supplies the attribute; nothing to print. + got = parseRawWidget(nil, dropdownFilterRaw("auto", "")) + if len(got) != 1 || len(got[0].FilterAttributes) != 0 { + t.Errorf("auto filter described with attributes %v, want none", filterAttrsOf(got)) + } +} + +func filterAttrsOf(ws []rawWidget) [][]string { + out := make([][]string, 0, len(ws)) + for _, w := range ws { + out = append(out, w.FilterAttributes) + } + return out +} + +// The configuration check reads is the mode the widget BUILDS in. Reading every +// mode's constants let the default mode's attrChoice "auto" and the association +// mode's baseType "ref" stand for a filter given `attributes:`, and MDL-WIDGET10 +// then called its `attr` hidden twice over (seen with a project's lifted rules). +func TestWidgetValueMap1519_ReadsOnlyTheActiveMode(t *testing.T) { + w := parseSnippetWidget(t, `dropdownfilter ddf (attributes: [Status])`) + values, _ := widgetValueMap(w, realDropdownFilterDef(t)) + if got := values["attrchoice"]; got != "linked" { + t.Errorf("attrChoice = %q, want linked (the attribute mode's constant)", got) + } + if got, ok := values["basetype"]; ok { + t.Errorf("baseType = %q, but only the association mode writes it", got) + } +} diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 747560200c..94627c3c3c 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -421,6 +421,9 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* if err != nil { return nil, mdlerrors.NewBackend("resolve mapping for "+mapping.PropertyKey, err) } + if err := refuseUndeclaredMappedKey(builder, def, mapping, ctx, w); err != nil { + return nil, err + } if err := e.applyOperation(builder, mapping.Operation, mapping.PropertyKey, ctx); err != nil { return nil, err @@ -977,12 +980,82 @@ func (e *PluggableWidgetEngine) applyOperation(builder backend.WidgetObjectBuild return nil } +// singleAttributeOverflow finds the mapping, in the mode the widget builds in, +// that feeds the `attributes:` list into ONE attribute property — and reports +// whether the script listed more than one. Shared by exec and check (#1519). +func singleAttributeOverflow(def *WidgetDefinition, w *ast.WidgetV3) (PropertyMapping, []string, bool) { + attrs := w.GetAttributes() + if def == nil || len(attrs) < 2 { + return PropertyMapping{}, nil, false + } + mappings, _, err := (&PluggableWidgetEngine{}).selectMappings(def, w) + if err != nil { + return PropertyMapping{}, nil, false + } + for _, m := range mappings { + if m.Source == "Attributes" && m.Operation == "attribute" { + return m, attrs, true + } + } + return PropertyMapping{}, nil, false +} + +func singleAttributeOverflowMessage(w *ast.WidgetV3, m PropertyMapping, attrs []string) string { + return fmt.Sprintf("widget `%s` (%s) filters on one attribute, stored on `%s`, but `attributes:` lists %d (%s) — "+ + "name one, or use a textfilter, which takes several", + w.Name, w.Type, m.PropertyKey, len(attrs), strings.Join(attrs, ", ")) +} + +// refuseUndeclaredMappedKey fails the build when a mapping carries a value the +// SCRIPT authored to a key the loaded widget does not declare. Every builder +// operation skips an unknown key without a word, so the value was dropped while +// check, exec and mx check all stayed clean — #1519's drop-down filter, whose +// attribute went to the text filter's `attributes` key. A mapping's own constant +// (`value` in the .def.json) is not checked: it is the definition's default, not +// the user's data, and an older widget version lacking the key is not the +// script's fault. +func refuseUndeclaredMappedKey(builder backend.WidgetObjectBuilder, def *WidgetDefinition, mapping PropertyMapping, ctx *BuildContext, w *ast.WidgetV3) error { + if mapping.Value != "" || !ctx.carriesValue() { + return nil + } + declarer, ok := builder.(backend.WidgetPropertyDeclarer) + if !ok || declarer.DeclaresProperty(mapping.PropertyKey) { + return nil + } + return mdlerrors.NewValidationf( + "widget `%s` (%s): its definition maps `%s` to property `%s`, which the installed widget %s does not declare — "+ + "the value would be dropped. The definition does not match this widget version", + w.Name, def.MDLName, mappingSourceLabel(mapping), mapping.PropertyKey, def.WidgetID) +} + +func mappingSourceLabel(m PropertyMapping) string { + if m.Source != "" { + return m.Source + } + return m.PropertyKey +} + +// carriesValue reports whether resolving a mapping produced anything to write. +func (c *BuildContext) carriesValue() bool { + return c.AttributePath != "" || len(c.AttributePaths) > 0 || c.AssocPath != "" || + c.PrimitiveVal != "" || c.DataSource != nil || c.Action != nil || c.ImageEntity != "" +} + // selectMappings selects the active PropertyMappings and ChildSlotMappings based on mode. func (e *PluggableWidgetEngine) selectMappings(def *WidgetDefinition, w *ast.WidgetV3) ([]PropertyMapping, []ChildSlotMapping, error) { if len(def.Modes) == 0 { return def.PropertyMappings, def.ChildSlots, nil } + mode, err := e.selectMode(def, w) + if err != nil { + return nil, nil, err + } + return mode.PropertyMappings, mode.ChildSlots, nil +} +// selectMode returns the mode a widget builds in: the first whose condition +// holds, else the single unconditioned one. A definition without modes has none. +func (e *PluggableWidgetEngine) selectMode(def *WidgetDefinition, w *ast.WidgetV3) (*WidgetMode, error) { var fallback *WidgetMode var fallbackCount int for i := range def.Modes { @@ -995,18 +1068,18 @@ func (e *PluggableWidgetEngine) selectMappings(def *WidgetDefinition, w *ast.Wid continue } if e.evaluateCondition(mode.Condition, w, mode.PropertyMappings) { - return mode.PropertyMappings, mode.ChildSlots, nil + return mode, nil } } if fallback != nil { if fallbackCount > 1 { - return nil, nil, mdlerrors.NewValidationf("widget %s has %d modes without conditions; only one default mode is allowed", def.MDLName, fallbackCount) + return nil, mdlerrors.NewValidationf("widget %s has %d modes without conditions; only one default mode is allowed", def.MDLName, fallbackCount) } - return fallback.PropertyMappings, fallback.ChildSlots, nil + return fallback, nil } - return nil, nil, mdlerrors.NewValidationf("no matching mode for widget %s", def.MDLName) + return nil, mdlerrors.NewValidationf("no matching mode for widget %s", def.MDLName) } // evaluateCondition checks a built-in condition string against the AST widget. @@ -1039,6 +1112,10 @@ func (e *PluggableWidgetEngine) evaluateCondition(condition string, w *ast.Widge return false case condition == "hasAttribute": return w.GetAttribute() != "" + case condition == "hasAttributes": + // `attributes: [A, …]` — a drop-down filter's custom attribute, which + // selects attrChoice "linked" rather than the column's own (#1519). + return len(w.GetAttributes()) > 0 case strings.HasPrefix(condition, "hasProp:"): propName := strings.TrimPrefix(condition, "hasProp:") return w.GetStringProp(propName) != "" @@ -1464,6 +1541,16 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W case "Attributes": if attrs := w.GetAttributes(); len(attrs) > 0 { entity := e.entityContextFor(mapping.PropertyKey) + if mapping.Operation == "attribute" { + // The list keyword feeding a SINGLE attribute property — a + // drop-down filter's `attr` (#1519). Keeping the first and + // discarding the rest would be the same silent drop as before. + if len(attrs) > 1 { + return nil, mdlerrors.NewValidation(singleAttributeOverflowMessage(w, mapping, attrs)) + } + ctx.AttributePath = e.pageBuilder.resolveAttributePathForEntity(attrs[0], entity) + break + } ctx.AttributePaths = make([]string, 0, len(attrs)) for _, attr := range attrs { ctx.AttributePaths = append(ctx.AttributePaths, diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index 035df6208b..c9ff065415 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -265,8 +265,11 @@ func (r *WidgetRegistry) validateDefinitionOperations(def *WidgetDefinition, sou // sourceOperationCompatible checks that a mapping's Source and Operation are compatible. var incompatibleSourceOps = map[string]map[string]bool{ - "Attribute": {"association": true, "datasource": true}, - "Attributes": {"association": true, "datasource": true, "attribute": true}, + "Attribute": {"association": true, "datasource": true}, + // Attributes → attribute is the list keyword feeding ONE attribute property + // (a drop-down filter's `attr`, #1519); resolveMapping refuses a list of more + // than one rather than keeping the first. + "Attributes": {"association": true, "datasource": true}, "Association": {"attribute": true, "datasource": true}, "DataSource": {"attribute": true, "association": true}, } diff --git a/sdk/widgets/definitions/dropdownfilter.def.json b/sdk/widgets/definitions/dropdownfilter.def.json index 6ceb457775..d46a976777 100644 --- a/sdk/widgets/definitions/dropdownfilter.def.json +++ b/sdk/widgets/definitions/dropdownfilter.def.json @@ -37,22 +37,29 @@ ] }, { - "name": "default", - "description": "Attribute mode — filter on the column's own attribute.", + "name": "attribute", + "condition": "hasAttributes", + "description": "Custom attribute — `attributes: [Attr]` names the ONE attribute the filter compares. DataWidgets 3.x stores it on `attr`, which the widget's editorConfig shows only when attrChoice is \"linked\" (Custom); it has no `attributes` list and no `defaultFilter`, the text filter's keys this mode used to write to, so the attribute was dropped (mendixlabs/mxcli#1519). This is the form a gallery's filter bar needs: there is no column to inherit an attribute from. More than one attribute is refused — the widget has a single slot.", "propertyMappings": [ { "propertyKey": "attrChoice", - "value": "auto", + "value": "linked", "operation": "primitive" }, { - "propertyKey": "attributes", + "propertyKey": "attr", "source": "Attributes", - "operation": "attributeObjects" - }, + "operation": "attribute" + } + ] + }, + { + "name": "default", + "description": "Auto — no attribute given: the filter takes the attribute of the Data grid 2 column it sits in (attrChoice \"auto\"), so it only works inside a column.", + "propertyMappings": [ { - "propertyKey": "defaultFilter", - "source": "FilterType", + "propertyKey": "attrChoice", + "value": "auto", "operation": "primitive" } ] From 91352c916d60ed580831b581427844978ed20177 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:57:59 +0000 Subject: [PATCH 11/25] fix(executor): check the variables a workflow expression names (mendixlabs/mxcli#1529) `call microflow M.ACT_Bind(Workflow = $Workflow, Item = $WorkflowContext)` in a workflow passed `check --references` and `exec`, then the build failed CE0117 "Error(s) in expression.". No workflow rule looked at the variables an expression names, and the code asserted the context was the only one in scope; `$WorkflowInstance` (System.Workflow) was nowhere in mxcli. The same root cause behind mendixlabs/mxcli#1523: the unmapped-parameter error suggested the deprecated `with (Workflow = ...)` and never what value to pass, and with no argument list auto-bind mapped every parameter, a System.Workflow one included, to $WorkflowContext. Scope measured with mx check on 11.6.6 and 11.15.0 (identical verdicts): $WorkflowContext and $WorkflowInstance build in call microflow arguments, decisions, wait-for-timer, user task due dates, boundary timers and event sub-process timers; the workflow's own header due date sees only the context. $Workflow, $currentUser, $currentSession and $WorkflowUserTask are CE0117 everywhere, and so is a lower-case $workflowInstance as stored. - MDL-WF19 (validate_workflow_scope.go): every $name outside a string literal in a workflow expression must be WorkflowContext (any case), the header's declared name, or WorkflowInstance (not in the header due date). Applied by check to CREATE and ALTER, and refused by exec too, since exec is reachable without check. The message names $WorkflowInstance for $Workflow. - Auto-bind maps a System.Workflow parameter to $WorkflowInstance. - The unmapped-parameter error names the current `call microflow M(P = v)` form, with $WorkflowInstance / $WorkflowContext by parameter type. - The writer rewrites the declared context name and casing (and now $WorkflowInstance casing) at every expression site; boundary timers, event sub-process timer starts, call-workflow arguments and the header due date used to be written verbatim, which the rule's allow-list would otherwise have hidden. mx check on a fixture copy: the probe set went from 10 x CE0117 to 0 errors on both versions once the out-of-scope names were refused and auto-bind fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...flow-expression-variable-scope-ce0117.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 46 ++-- cmd/mxcli/syntax/features_workflow.go | 9 +- docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../1529-workflow-instance-variable.mdl | 33 +++ ...29-workflow-out-of-scope-variable.fail.mdl | 18 ++ mdl/executor/cmd_alter_workflow.go | 14 +- mdl/executor/cmd_workflows_scope_bind_test.go | 164 ++++++++++++++ mdl/executor/cmd_workflows_write.go | 67 +++++- mdl/executor/validate_workflow.go | 5 + mdl/executor/validate_workflow_refs.go | 39 +++- mdl/executor/validate_workflow_scope.go | 206 ++++++++++++++++++ mdl/executor/validate_workflow_scope_test.go | 168 ++++++++++++++ 13 files changed, 726 insertions(+), 45 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-workflow-expression-variable-scope-ce0117.json create mode 100644 mdl-examples/bug-tests/1529-workflow-instance-variable.mdl create mode 100644 mdl-examples/bug-tests/1529-workflow-out-of-scope-variable.fail.mdl create mode 100644 mdl/executor/cmd_workflows_scope_bind_test.go create mode 100644 mdl/executor/validate_workflow_scope.go create mode 100644 mdl/executor/validate_workflow_scope_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-workflow-expression-variable-scope-ce0117.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-workflow-expression-variable-scope-ce0117.json new file mode 100644 index 0000000000..f1a22fa381 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-workflow-expression-variable-scope-ce0117.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "In a workflow, `call microflow M.ACT_Bind(Workflow = $Workflow, Item = $WorkflowContext);` passed `check --references` and `exec`, then mx check failed CE0117 \"Error(s) in expression.\" (mendixlabs/mxcli#1529). The unmapped-parameter error for a System.Workflow parameter suggested the deprecated `with (Workflow = ...)` and never said what to pass, so authors guessed $Workflow; with no argument list, auto-bind mapped EVERY parameter to $WorkflowContext, so a System.Workflow parameter got the context object (mendixlabs/mxcli#1523).", "cause": "No workflow rule looked at the variables an expression names: MDL-WF01..17 check structure, and the writer only rewrote the context's spelling. Code comments asserted 'the only in-scope variable is the context' — wrong: $WorkflowInstance (System.Workflow) is in scope too, and appeared nowhere in Go code or skills. The writer also skipped boundary timers, event sub-process timer starts, call-workflow arguments and the header due date when rewriting the declared context name, so `$Ctx` there reached Mendix verbatim.", "fix": "MDL-WF19 (validate_workflow_scope.go): scan $names outside string literals in every workflow expression (call microflow/workflow arguments, decision, wait for timer, user task due date, boundary timers, ESP timer start, header due date; ALTER's inserts, boundary delay and set due_date) — allowed: any case of WorkflowContext, the declared header name, and WorkflowInstance except in the header due date; refused at check and at exec. Auto-bind maps a System.Workflow parameter to $WorkflowInstance; the unmapped message names the current `call microflow M(P = value)` form with a type-aware value. The writer normalizes the context alias/casing and $WorkflowInstance casing at every expression site.", "file": "mdl/executor/validate_workflow_scope.go", "insight": "The scope table was settled by one mx check run per expression kind on 11.6.6 and 11.15.0 (identical): $WorkflowInstance builds everywhere EXCEPT the workflow's own due date (evaluated before the instance exists); $Workflow, $currentUser, $currentSession and $WorkflowUserTask are CE0117 everywhere; lower-case $workflowInstance is CE0117 as stored. mx check reports CE0117 'at Decision ''Decision''' / 'at User task ''U''', not per document, so give every probe activity a distinct caption or the verdicts cannot be attributed. A 'which variables are in scope' rule is only false-positive-safe when the writer's rewrites and the rule's allow-list are the same set — they had drifted (alias rewritten in a decision, written verbatim in a boundary timer), so the fix had to close the writer gap before the allow-list was honest."} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 83ee2f54eb..4bc80a4407 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -11,18 +11,18 @@ description: "Author Mendix workflows in MDL — user tasks, decisions, parallel | lines | section | |---|---| | 35-46 | When to Use This Skill | -| 47-110 | Syntax — CREATE WORKFLOW | -| 111-282 | Activities | -| 283-289 | DROP WORKFLOW | -| 290-355 | ALTER WORKFLOW | -| 356-376 | DESCRIBE round-trip | -| 377-397 | Activity names, and why `jump to` depends on them | -| 398-418 | Rewriting an existing workflow | -| 419-506 | Microflow statements for workflow tasks | -| 507-527 | System-module enumerations are synthesized, not stored | -| 528-644 | Platform rules | -| 645-675 | Observing a running workflow | -| 676-685 | Validate before presenting | +| 47-117 | Syntax — CREATE WORKFLOW | +| 118-289 | Activities | +| 290-296 | DROP WORKFLOW | +| 297-362 | ALTER WORKFLOW | +| 363-383 | DESCRIBE round-trip | +| 384-404 | Activity names, and why `jump to` depends on them | +| 405-425 | Rewriting an existing workflow | +| 426-513 | Microflow statements for workflow tasks | +| 514-534 | System-module enumerations are synthesized, not stored | +| 535-652 | Platform rules | +| 653-683 | Observing a running workflow | +| 684-693 | Validate before presenting | # Mendix Workflows Skill @@ -101,10 +101,17 @@ written **last** silently winning. variable in the header, mxcli writes the parameter as `WorkflowContext`, so `$WorkflowContext/Attribute` is the canonical way to reach it in an expression. The name you declared (`$Context` above) and any casing of the canonical name -(`$workflowContext`) are rewritten to it on write — in decision conditions, user -task due dates and XPath targeting, wait-for-timer delays, and `with (…)` -parameter mappings. Anything else is an undefined variable and Mendix fails the -build with `CE0117 "Error(s) in expression."`. +(`$workflowContext`) are rewritten to it on write, in every workflow expression. + +**The only other variable is `$WorkflowInstance`** — the running workflow +(`System.Workflow`), what a microflow's `Workflow: System.Workflow` parameter +takes: `call microflow M.ACT_Log(Workflow = $WorkflowInstance, Item = +$WorkflowContext)`. **Not `$Workflow`** (the microflow's parameter name), and +not `$currentUser`, `$currentSession` or `$WorkflowUserTask` — each is `CE0117 +"Error(s) in expression."`, and `check` refuses them as `MDL-WF19`. One place +sees only the context: the workflow's own header `due date`. Measured on 11.6.6 +and 11.15.0. A call with no argument list binds a `System.Workflow` parameter to +`$WorkflowInstance` and every other one to the context. `create or modify workflow …` is supported (`create or replace` is its deprecated spelling, MDL-DEPR001). @@ -638,9 +645,10 @@ values. The full list and the System **entities** are in `system-module`. string, still parses with the same meaning but is deprecated (MDL-DEPR008); `mxcli fmt --upgrade` rewrites it. - 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`. +- Write the context variable as **`$WorkflowContext`** and the workflow as + **`$WorkflowInstance`**, spelled exactly. Mendix expressions are + case-sensitive, so a lowercase `$workflowInstance` is `CE0117` as stored; + mxcli rewrites the casing of both on write. ## Observing a running workflow diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 2abcf253f9..1b77ad50f9 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -276,8 +276,13 @@ func init() { "-- WITH ( = ''), the expression in a string, is the\n" + "-- deprecated spelling of the argument list (MDL-DEPR008).\n" + "-- CAPTION sets the caption the activity shows; COMMENT '', its old\n" + - "-- spelling on every workflow activity, is a deprecated alias (MDL-DEPR104).", - Example: "CALL MICROFLOW HR.SendNotification\n CAPTION 'Notify manager';\n\n-- Arguments are named by their BARE parameter name:\nCALL MICROFLOW HR.Escalate(Request = $WorkflowContext) AS callMicroflow1;", + "-- spelling on every workflow activity, is a deprecated alias (MDL-DEPR104).\n\n" + + "-- An argument can name only $WorkflowContext and $WorkflowInstance (the\n" + + "-- running workflow, System.Workflow). $Workflow, $currentUser and the like\n" + + "-- are CE0117 at build (MDL-WF19). With no argument list, a System.Workflow\n" + + "-- parameter is bound to $WorkflowInstance and every other to the context.", + Example: "CALL MICROFLOW HR.SendNotification\n CAPTION 'Notify manager';\n\n-- Arguments are named by their BARE parameter name:\nCALL MICROFLOW HR.Escalate(Request = $WorkflowContext) AS callMicroflow1;\n\n" + + "-- A System.Workflow parameter takes the running workflow:\nCALL MICROFLOW HR.ACT_Audit(Workflow = $WorkflowInstance, Request = $WorkflowContext);", SeeAlso: []string{"workflow.create", "workflow.call-workflow", "workflow.ai-agent-task"}, }) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0aba7bb301..1b01ddfd24 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -763,6 +763,7 @@ mandatory and a misplaced clause failed with a token error - `end workflow [caption ''];` — only inside a `{ }` block; ends the whole workflow - `caption '…'` sets the caption Studio Pro shows on an activity; `comment '…'`, the old spelling, is a deprecated alias (`MDL-DEPR104`) - Boundary events, after `outcomes`: `boundary event [non] interrupting timer { … }` or `boundary event [non] interrupting notification [''] { … }` (11.11+). One interrupting event per activity (CE6697, MDL-WF15). +- **Variables in a workflow expression**: `$WorkflowContext` (or the header's declared name) and `$WorkflowInstance`, the running `System.Workflow` — `call microflow M.ACT(Workflow = $WorkflowInstance)`. Anything else (`$Workflow`, `$currentUser`, `$WorkflowUserTask`, …) is CE0117 (MDL-WF19); the workflow's own `due date` sees only the context. **Notifying a workflow** (a microflow statement): `[$Notified =] notify workflow $Workflow target Module.Workflow.ElementName;` — the element is a notification-started event sub-process's start, a notification activity, a notification boundary event or a wait for notification, and mxcli resolves which. The target is required (CE0166, MDL-WF16). diff --git a/mdl-examples/bug-tests/1529-workflow-instance-variable.mdl b/mdl-examples/bug-tests/1529-workflow-instance-variable.mdl new file mode 100644 index 0000000000..2ef7109c4c --- /dev/null +++ b/mdl-examples/bug-tests/1529-workflow-instance-variable.mdl @@ -0,0 +1,33 @@ +mdl 1; +-- mendixlabs/mxcli#1529 / #1523: the two variables a workflow expression can see. +-- +-- `$WorkflowContext` is the context object; `$WorkflowInstance` is the running +-- workflow (System.Workflow) — what a microflow's `Workflow: System.Workflow` +-- parameter takes. Anything else (`$Workflow`, `$currentUser`, +-- `$currentSession`, `$WorkflowUserTask`) is CE0117 and refused by `check` as +-- MDL-WF19. The one exception: the workflow's OWN due date sees only the +-- context, so `$WorkflowInstance` there is CE0117 too. +-- +-- Measured with mx check on 11.6.6 and 11.15.0: this script builds with 0 +-- errors. With no argument list, `call microflow WFI.ACT_Bind;` now binds the +-- System.Workflow parameter to $WorkflowInstance rather than to the context. + +create module WFI; +create persistent entity WFI.Item ( Title: string(100) ); +create microflow WFI.ACT_Bind ($Workflow: System.Workflow, $Item: WFI.Item) begin return; end; +create page WFI.TaskPage ( title: 'T', layout: Atlas_Core.Atlas_Default, params: ( $WorkflowUserTask: System.WorkflowUserTask ) ) { }; + +create workflow WFI.InstanceInScope parameter $WorkflowContext: WFI.Item + due date addDays([%CurrentDateTime%], 7) +begin + call microflow WFI.ACT_Bind(Workflow = $WorkflowInstance, Item = $WorkflowContext); + decision d1 $WorkflowInstance/Name != empty + outcomes true -> { } false -> { }; + wait for timer t1 addDays($WorkflowInstance/StartTime, 1); + user task u1 'Review' page WFI.TaskPage + due date addDays($WorkflowInstance/StartTime, 3) + outcomes 'Done' { } + boundary event interrupting timer addDays($WorkflowInstance/StartTime, 5) { + call microflow WFI.ACT_Bind(Workflow = $WorkflowInstance, Item = $WorkflowContext); + }; +end workflow; diff --git a/mdl-examples/bug-tests/1529-workflow-out-of-scope-variable.fail.mdl b/mdl-examples/bug-tests/1529-workflow-out-of-scope-variable.fail.mdl new file mode 100644 index 0000000000..c284ddfff6 --- /dev/null +++ b/mdl-examples/bug-tests/1529-workflow-out-of-scope-variable.fail.mdl @@ -0,0 +1,18 @@ +-- NEGATIVE TEST (.fail.mdl) — EXPECTED to fail `mxcli check` with MDL-WF19. +-- +-- mendixlabs/mxcli#1529 / #1523. A microflow names its System.Workflow parameter +-- `$Workflow`, so that is what authors (and agents) write in a workflow. In a +-- workflow expression it is an undefined variable: `check --references` and +-- `exec` passed it, and the build then failed CE0117 "Error(s) in expression." +-- (measured with mx check on 11.6.6 and 11.15.0). The running workflow is +-- `$WorkflowInstance`; see 1529-workflow-instance-variable.mdl for the form that +-- builds. + +create module WFS; +create persistent entity WFS.Item ( Title: string(100) ); +create microflow WFS.ACT_Bind ($Workflow: System.Workflow, $Item: WFS.Item) begin return; end; + +create workflow WFS.OutOfScope parameter $WorkflowContext: WFS.Item +begin + call microflow WFS.ACT_Bind(Workflow = $Workflow, Item = $WorkflowContext); +end workflow; diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index 2547287db4..e3636ca505 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -39,6 +39,10 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { if err := refuseWorkflowReturn(alterWorkflowAddedActivities(s)); err != nil { return err } + // MDL-WF19, as for CREATE (mendixlabs/mxcli#1529). + if vs := checkWorkflowExprScope(alterWorkflowExprSites(s), "", workflowLocation(s.Name)); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s\n → %s", s.Name.String(), vs[0].Message, vs[0].Suggestion) + } if refErrors := validateAlterWorkflowRefs(ctx, s, nil); len(refErrors) > 0 { return mdlerrors.NewValidationf("workflow '%s' has reference errors:\n - %s", s.Name.String(), strings.Join(refErrors, "\n - ")) @@ -108,7 +112,11 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return mdlerrors.NewBackend("set "+o.Property, err) } default: - if err := mutator.SetProperty(o.Property, o.Value); err != nil { + value := o.Value + if o.Property == "due_date" { + value = normalizeWorkflowContextExpr(value) // MDL-WF19's accepted casings + } + if err := mutator.SetProperty(o.Property, value); err != nil { return mdlerrors.NewBackend("set "+o.Property, err) } } @@ -120,6 +128,8 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { value = o.PageName.Module + "." + o.PageName.Name case "targeting_microflow": value = o.Microflow.Module + "." + o.Microflow.Name + case "due_date": + value = normalizeWorkflowContextExpr(value) // MDL-WF19's accepted casings } if err := mutator.SetActivityProperty(o.ActivityRef, o.AtPosition, o.Property, value); err != nil { return mdlerrors.NewBackend("set activity", err) @@ -198,7 +208,7 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { "restate the workflow with CREATE OR MODIFY WORKFLOW, which accepts `boundary event interrupting notification '' { … }`") } acts := buildAndBindActivities(ctx, o.Activities) - if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, o.Delay, acts); err != nil { + if err := mutator.InsertBoundaryEvent(o.ActivityRef, o.AtPosition, o.EventType, normalizeWorkflowContextExpr(o.Delay), acts); err != nil { return mdlerrors.NewBackend("insert boundary event", err) } diff --git a/mdl/executor/cmd_workflows_scope_bind_test.go b/mdl/executor/cmd_workflows_scope_bind_test.go new file mode 100644 index 0000000000..905f7d547b --- /dev/null +++ b/mdl/executor/cmd_workflows_scope_bind_test.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// mendixlabs/mxcli#1523: with no argument list, a workflow call-microflow bound +// EVERY parameter of its target to $WorkflowContext. A `Workflow: System.Workflow` +// parameter then received the context object — the wrong type — and the value +// it needs is $WorkflowInstance (measured 0 errors on 11.6.6 and 11.15.0 when +// passed explicitly). +func TestAutoBindCallMicroflow_SystemWorkflowParameter(t *testing.T) { + mod := mkModule("WF") + mf := mkMicroflow(mod.ID, "ACT_Bind") + mf.Parameters = []*microflows.MicroflowParameter{ + {BaseElement: model.BaseElement{ID: nextID("param")}, Name: "Workflow", + Type: µflows.ObjectType{EntityQualifiedName: "System.Workflow"}}, + {BaseElement: model.BaseElement{ID: nextID("param")}, Name: "Item", + Type: µflows.ObjectType{EntityQualifiedName: "WF.Item"}}, + } + h := mkHierarchy(mod) + withContainer(h, mf.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return []*microflows.Microflow{mf}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + + task := &workflows.CallMicroflowTask{Microflow: "WF.ACT_Bind"} + task.ID = model.ID(nextID("act")) + task.Name = "callMicroflow1" + autoBindWorkflowParameters(ctx, []workflows.WorkflowActivity{task}, "WorkflowContext") + + got := map[string]string{} + for _, pm := range task.ParameterMappings { + got[pm.Parameter] = pm.Expression + } + want := map[string]string{ + "WF.ACT_Bind.Workflow": "$WorkflowInstance", + "WF.ACT_Bind.Item": "$WorkflowContext", // the control: unchanged + } + for p, w := range want { + if got[p] != w { + t.Errorf("mapping %s = %q, want %q", p, got[p], w) + } + } +} + +// MDL-WF19 accepts the declared header name and any casing of the two scope +// variables because the writer rewrites them. These are the expression sites the +// writer used to skip — a boundary timer, an event sub-process timer start and a +// call-workflow argument — so the header name reached Mendix verbatim (CE0117). +// The decision is the control: it was always rewritten. +func TestAutoBind_RewritesEveryWorkflowExpressionSite(t *testing.T) { + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{IsConnectedFunc: func() bool { return true }})) + + decision := &workflows.ExclusiveSplitActivity{Expression: "$Ctx/Ok and $workflowInstance/Name != empty"} + decision.ID = model.ID(nextID("act")) + decision.Name = "decision1" + + be := &workflows.BoundaryEvent{EventType: "InterruptingTimer", TimerDelay: "addDays($Ctx/Due, 1)", Flow: mkWfFlow()} + be.ID = model.ID(nextID("be")) + ut := &workflows.UserTask{BoundaryEvents: []*workflows.BoundaryEvent{be}} + ut.ID = model.ID(nextID("act")) + ut.Name = "userTask1" + + start := &workflows.EventSubProcessStartActivity{Timer: true, FirstExecutionTime: "addDays($workflowinstance/StartTime, 1)"} + start.ID = model.ID(nextID("act")) + start.Name = "espStart" + + pm := &workflows.ParameterMapping{Parameter: "WF.Sub.WorkflowContext", Expression: "$Ctx"} + cw := &workflows.CallWorkflowActivity{Workflow: "WF.Sub", ParameterMappings: []*workflows.ParameterMapping{pm}} + cw.ID = model.ID(nextID("act")) + cw.Name = "callWorkflow1" + + autoBindWorkflowParameters(ctx, []workflows.WorkflowActivity{decision, ut, start, cw}, "$Ctx") + + checks := []struct{ where, got, want string }{ + {"decision (control)", decision.Expression, "$WorkflowContext/Ok and $WorkflowInstance/Name != empty"}, + {"boundary timer", be.TimerDelay, "addDays($WorkflowContext/Due, 1)"}, + {"event sub-process timer start", start.FirstExecutionTime, "addDays($WorkflowInstance/StartTime, 1)"}, + {"call workflow argument", pm.Expression, "$WorkflowContext"}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.where, c.got, c.want) + } + } +} + +// exec is reachable without check (-c, the REPL, --no-check), so MDL-WF19 is +// refused there too — otherwise the CE0117 model is written anyway (#833's reason). +func TestExecWorkflow_RefusesOutOfScopeVariable(t *testing.T) { + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{IsConnectedFunc: func() bool { return true }})) + create := parseMDL(t, "create workflow M.W parameter $WorkflowContext: M.Item begin\n"+ + " call microflow M.ACT_Bind(Workflow = $Workflow, Item = $WorkflowContext);\nend workflow;") + err := execCreateWorkflow(ctx, create.Statements[0].(*ast.CreateWorkflowStmt)) + if err == nil || !strings.Contains(err.Error(), "$WorkflowInstance") { + t.Errorf("create: err = %v, want a refusal naming $WorkflowInstance", err) + } + alter := parseMDL(t, "alter workflow M.W { insert after Review { call microflow M.ACT_Inst(Workflow = $Workflow); } };") + err = execAlterWorkflow(ctx, alter.Statements[0].(*ast.AlterWorkflowStmt)) + if err == nil || !strings.Contains(err.Error(), "$WorkflowInstance") { + t.Errorf("alter: err = %v, want a refusal naming $WorkflowInstance", err) + } +} + +// mendixlabs/mxcli#1523: the unmapped-parameter error suggested the deprecated +// `with (Workflow = ...)` (MDL-DEPR008) and nothing about WHAT to pass, and the +// author guessed $Workflow — CE0117. It now names the current argument form and, +// per parameter type, the value: $WorkflowInstance for System.Workflow, the +// context for the context entity. +func TestWorkflowParameterMappings_TypeAwareAdvice(t *testing.T) { + mod := &model.Module{Name: "M"} + mod.ID = "mod1" + mf := µflows.Microflow{Name: "ACT_Bind", Parameters: []*microflows.MicroflowParameter{ + {Name: "Workflow", Type: µflows.ObjectType{EntityQualifiedName: "System.Workflow"}}, + {Name: "Item", Type: µflows.ObjectType{EntityQualifiedName: "M.Item"}}, + {Name: "Note", Type: µflows.StringType{}}, + }} + mf.ContainerID = "mod1" + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return []*microflows.Microflow{mf}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + s := parseWorkflowStmt(t, "create workflow M.W parameter $WorkflowContext: M.Item begin call microflow M.ACT_Bind; end workflow;") + errs := validateWorkflowParameterMappings(ctx, s, nil) + if len(errs) != 3 { + t.Fatalf("got %d errors, want 3: %v", len(errs), errs) + } + want := []string{ + "call microflow M.ACT_Bind(Workflow = $WorkflowInstance)", + "call microflow M.ACT_Bind(Item = $WorkflowContext)", + "call microflow M.ACT_Bind(Note = ...)", + } + for i, w := range want { + if !strings.Contains(errs[i], w) { + t.Errorf("error %d = %q, want it to contain %q", i, errs[i], w) + } + if strings.Contains(errs[i], "with (") { + t.Errorf("error %d still suggests the deprecated with (…) form: %q", i, errs[i]) + } + } +} + +// The header alias must not swallow a longer name that starts with it: a header +// `parameter $Workflow: …` rewrites $Workflow, never $WorkflowInstance. +func TestContextExprNormalizer_AliasPrefixOfInstance(t *testing.T) { + n := newContextExprNormalizer("$Workflow") + if got, want := n.rewrite("$Workflow/Title + $WorkflowInstance/Name"), "$WorkflowContext/Title + $WorkflowInstance/Name"; got != want { + t.Errorf("rewrite = %q, want %q", got, want) + } +} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 01848e9e19..db030b1a2c 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -74,6 +74,11 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { if vs := ValidateWorkflowEventTypes(s); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } + // MDL-WF19: a variable no workflow expression can see is CE0117 at build + // time (mendixlabs/mxcli#1529). + if vs := ValidateWorkflowExpressionScope(s); len(vs) > 0 { + return mdlerrors.NewValidationf("workflow '%s': %s\n → %s", s.Name.String(), vs[0].Message, vs[0].Suggestion) + } if err := checkVersionGates(ctx, s); err != nil { return err } @@ -173,8 +178,9 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { wf.ExportLevel = s.ExportLevel } - // Due date - wf.DueDate = s.DueDate + // Due date — an expression over the context, so its declared name and + // casing are rewritten like every activity expression's (mendixlabs/mxcli#1529). + wf.DueDate = newContextExprNormalizer(s.ParameterVar).rewrite(s.DueDate) handlers, err := buildWorkflowEventHandlers(ctx, s.EventHandlers, existingHandlers) if err != nil { @@ -1084,7 +1090,12 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA case *workflows.CallMicroflowTask: autoBindCallMicroflow(ctx, a, norm) case *workflows.CallWorkflowActivity: + for _, pm := range a.ParameterMappings { + pm.Expression = norm.rewrite(pm.Expression) + } autoBindCallWorkflow(ctx, a) + case *workflows.EventSubProcessStartActivity: + a.FirstExecutionTime = norm.rewrite(a.FirstExecutionTime) case *workflows.UserTask: // Sanitize name a.Name = sanitizeActivityName(a.Name) @@ -1097,8 +1108,8 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA a.Name = sanitizeActivityName(a.Name) case *workflows.ExclusiveSplitActivity: a.Name = sanitizeActivityName(a.Name) - // A decision's condition is an expression over the workflow context, - // and the only in-scope variable is that context. It was left verbatim + // A decision's condition is an expression over the workflow context + // (and $WorkflowInstance — the two variables in scope). It was left verbatim // while call-microflow parameter mappings were normalized, so the // documented `$workflowContext` (and the user's own declared parameter // name) reached Mendix as undefined variables → CE0117 (issuetracker @@ -1113,6 +1124,15 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA a.Name = sanitizeActivityName(a.Name) } + // A boundary timer, an event sub-process timer start and a call-workflow + // argument are expressions in the same scope as a decision, but were + // written verbatim, so the header's declared name reached Mendix as an + // undefined variable (CE0117) where a decision's was rewritten + // (mendixlabs/mxcli#1529). + for _, be := range activityBoundaryEvents(act) { + be.TimerDelay = norm.rewrite(be.TimerDelay) + } + // Every nested flow, whatever the activity — enumerating them here rather // than per case is what keeps a decision's enum branch and a boundary // event body from being skipped (ako/mxcli#417). @@ -1159,12 +1179,15 @@ func autoBindCallMicroflow(ctx *ExecContext, task *workflows.CallMicroflowTask, task.Outcomes = defaultCallMicroflowOutcomes(targetMF) } - // Auto-bind parameters (context) if no explicit mappings were given. + // Auto-bind parameters if no explicit mappings were given: a System.Workflow + // parameter takes the running workflow, $WorkflowInstance; every other one + // the context. Binding the context to a System.Workflow parameter passed the + // wrong object (mendixlabs/mxcli#1523). if len(task.ParameterMappings) == 0 && targetMF != nil { for _, param := range targetMF.Parameters { mapping := &workflows.ParameterMapping{ Parameter: task.Microflow + "." + param.Name, - Expression: "$WorkflowContext", + Expression: workflowParameterDefault(param.Type), } mapping.BaseElement.ID = model.ID(types.GenerateID()) task.ParameterMappings = append(task.ParameterMappings, mapping) @@ -1172,6 +1195,22 @@ func autoBindCallMicroflow(ctx *ExecContext, task *workflows.CallMicroflowTask, } } +// workflowParameterDefault is the expression a workflow call-microflow binds to +// a target parameter of type t when the script gives no argument list. +func workflowParameterDefault(t microflows.DataType) string { + if isSystemWorkflowType(t) { + return "$" + workflowInstanceVar + } + return "$" + workflowContextVar +} + +// isSystemWorkflowType reports whether t is an object of System.Workflow — the +// type of $WorkflowInstance. +func isSystemWorkflowType(t microflows.DataType) bool { + o, ok := t.(*microflows.ObjectType) + return ok && strings.EqualFold(o.EntityQualifiedName, "System.Workflow") +} + // defaultCallMicroflowOutcomes builds the default outcome set for a call-microflow // activity based on the target microflow's return type: Boolean → true/false // branches; anything else (void, entity, string, …) → a single default outcome. @@ -1200,14 +1239,20 @@ func defaultCallMicroflowOutcomes(mf *microflows.Microflow) []workflows.Conditio // context has to match it exactly or the build fails CE0117. const workflowContextVar = "WorkflowContext" -// normalizeWorkflowContextExpr rewrites a case-insensitive `$workflowContext` -// reference to the exact context parameter name `$WorkflowContext`. In a workflow -// the only in-scope variable is the context, so this is unambiguous. +// normalizeWorkflowContextExpr rewrites a case-insensitive reference to either +// variable a workflow expression can see — `$WorkflowContext` and +// `$WorkflowInstance` (MDL-WF19, validate_workflow_scope.go) — to its exact +// spelling. Both are fixed names, so this is unambiguous; a lower-case +// `$workflowInstance` is CE0117 as written (measured on 11.6.6). func normalizeWorkflowContextExpr(expr string) string { - return workflowContextRe.ReplaceAllString(expr, "$$"+workflowContextVar) + expr = workflowContextRe.ReplaceAllString(expr, "$$"+workflowContextVar) + return workflowInstanceRe.ReplaceAllString(expr, "$$"+workflowInstanceVar) } -var workflowContextRe = regexp.MustCompile(`(?i)\$workflowcontext`) +var ( + workflowContextRe = regexp.MustCompile(`(?i)\$workflowcontext`) + workflowInstanceRe = regexp.MustCompile(`(?i)\$workflowinstance\b`) +) // contextExprNormalizer makes every way an author can name the workflow context // resolve to the one name it is actually stored under. diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index f197fa1bf2..9e4707ed81 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -59,6 +59,7 @@ var ( // - 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) // - MDL-WF08..11: where `end workflow` may go, and `return` (see validate_workflow_end.go) +// - MDL-WF19: a variable no workflow expression can see (see validate_workflow_scope.go) func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { var out []linter.Violation loc := linter.Location{ @@ -125,6 +126,7 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { out = append(out, ValidateWorkflowEventTypes(stmt)...) out = append(out, ValidateWorkflowCompletionRules(stmt)...) out = append(out, ValidateWorkflowEventSubProcesses(stmt)...) + out = append(out, ValidateWorkflowExpressionScope(stmt)...) return out } @@ -415,5 +417,8 @@ func ValidateAlterWorkflow(stmt *ast.AlterWorkflowStmt) []linter.Violation { out = append(out, *v) } } + // MDL-WF19 over every expression the statement writes. ALTER cannot see the + // header's declared context name, so only $WorkflowContext is accepted for it. + out = append(out, checkWorkflowExprScope(alterWorkflowExprSites(stmt), "", loc)...) return append(out, ValidateAlterWorkflowEnds(stmt)...) } diff --git a/mdl/executor/validate_workflow_refs.go b/mdl/executor/validate_workflow_refs.go index e9f1e799b3..1a647aed8b 100644 --- a/mdl/executor/validate_workflow_refs.go +++ b/mdl/executor/validate_workflow_refs.go @@ -7,8 +7,10 @@ package executor import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" ) // validateWorkflowParameterMappings checks that each workflow "call microflow" @@ -29,14 +31,11 @@ func validateWorkflowParameterMappings(ctx *ExecContext, s *ast.CreateWorkflowSt if err != nil { return nil } - paramsByMF := make(map[string][]string, len(mfs)) + paramsByMF := make(map[string][]*microflows.MicroflowParameter, len(mfs)) for _, mf := range mfs { - names := make([]string, 0, len(mf.Parameters)) - for _, p := range mf.Parameters { - names = append(names, p.Name) - } - paramsByMF[h.GetQualifiedName(mf.ContainerID, mf.Name)] = names + paramsByMF[h.GetQualifiedName(mf.ContainerID, mf.Name)] = mf.Parameters } + contextEntity := s.ParameterEntity.String() var errs []string walkWorkflowActivities(workflowStatementActivities(s), func(act ast.WorkflowActivityNode) { @@ -56,17 +55,35 @@ func validateWorkflowParameterMappings(ctx *ExecContext, s *ast.CreateWorkflowSt for _, pm := range cm.ParameterMappings { mapped[pm.Parameter] = true } - for _, name := range want { - if !mapped[name] { - errs = append(errs, fmt.Sprintf( - "call microflow '%s': parameter '%s' is not mapped — Mendix requires every parameter of a workflow call-microflow to be mapped (add `with (%s = ...)`)", - mfQN, name, name)) + for _, p := range want { + if p == nil || mapped[p.Name] { + continue + } + // Name the value as well as the form: told only "map it", authors + // guessed `$Workflow` for a System.Workflow parameter, which is CE0117 + // (mendixlabs/mxcli#1523). The argument list after the callee is the + // current form; `with (…)` is the deprecated MDL-DEPR008 spelling. + value, why := "...", "" + switch { + case isSystemWorkflowType(p.Type): + value, why = "$"+workflowInstanceVar, " — a System.Workflow parameter takes the running workflow, $WorkflowInstance" + case isObjectOfEntity(p.Type, contextEntity): + value = "$" + workflowContextVar } + errs = append(errs, fmt.Sprintf( + "call microflow '%s': parameter '%s' is not mapped — Mendix requires every parameter of a workflow call-microflow to be mapped (write `call microflow %s(%s = %s)`%s)", + mfQN, p.Name, mfQN, p.Name, value, why)) } }) return errs } +// isObjectOfEntity reports whether t is an object of the entity qn. +func isObjectOfEntity(t microflows.DataType, qn string) bool { + o, ok := t.(*microflows.ObjectType) + return ok && qn != "" && qn != "." && strings.EqualFold(o.EntityQualifiedName, qn) +} + // validateWorkflowReferences checks every qualified name a workflow's activities // point at against the project (and against what the same script creates). // diff --git a/mdl/executor/validate_workflow_scope.go b/mdl/executor/validate_workflow_scope.go new file mode 100644 index 0000000000..ebb6038492 --- /dev/null +++ b/mdl/executor/validate_workflow_scope.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "regexp" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// MDL-WF19: a variable a workflow expression cannot see (mendixlabs/mxcli#1529, +// #1523). +// +// A workflow declares no variables. What an expression in it can name is fixed +// by Mendix, and anything else is CE0117 "Error(s) in expression." at build time +// — after `check --references` and `exec` had both passed and the model was +// written. Measured with `mx check` on 11.6.6 and on 11.15.0, verdicts identical, +// one probe per expression kind: +// +// $WorkflowContext $WorkflowInstance anything else +// call microflow argument ok ok CE0117 +// call workflow argument ok (ok) CE0117 +// decision condition ok ok CE0117 +// wait for timer ok ok CE0117 +// user task due date ok ok CE0117 +// boundary event timer ok ok CE0117 +// event sub-process timer start ok ok CE0117 +// the workflow's own due date ok CE0117 CE0117 +// +// "Anything else" was measured with the names an author reaches for: +// `$Workflow` (what a microflow names its System.Workflow parameter — the +// report), `$currentUser`, `$currentSession` (a workflow runs with no user +// session) and `$WorkflowUserTask` (the task page's parameter, not in scope in +// the task's own due date or boundary timer). The call-workflow row's +// $WorkflowInstance was not probed directly; it is the same activity scope. +// +// Two spellings are accepted although Mendix's name differs, because the writer +// rewrites them: the header's declared name for the context and any casing of +// `$WorkflowContext` (contextExprNormalizer), and any casing of +// `$WorkflowInstance` (lower case is CE0117 as written, measured on 11.6.6). +// +// XPath targeting is not scanned: it is an XPath constraint, not an expression, +// and its scope was not measured. +const wfExprScopeRule = "MDL-WF19" + +// workflowInstanceVar is the System.Workflow object of the running instance — +// the value a microflow's `Workflow: System.Workflow` parameter is passed. +const workflowInstanceVar = "WorkflowInstance" + +// wfExprSite is one expression a workflow statement writes. +type wfExprSite struct { + where string // what holds it, for the message + expr string + // header marks the workflow's own due date, which is evaluated with only the + // context in scope (no instance yet). + header bool +} + +var ( + wfExprStringRe = regexp.MustCompile(`'(?:[^']|'')*'`) + wfExprVarRe = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)`) +) + +// workflowExprVars returns the `$names` an expression references, in order, +// ignoring anything inside a string literal. +func workflowExprVars(expr string) []string { + stripped := wfExprStringRe.ReplaceAllString(expr, "''") + var out []string + for _, m := range wfExprVarRe.FindAllStringSubmatch(stripped, -1) { + out = append(out, m[1]) + } + return out +} + +// workflowActivityExprSites collects the expressions the activities (and every +// nested flow) write. +func workflowActivityExprSites(acts []ast.WorkflowActivityNode) []wfExprSite { + var sites []wfExprSite + boundary := func(events []ast.WorkflowBoundaryEventNode, owner string) { + for _, b := range events { + if b.Delay != "" { + sites = append(sites, wfExprSite{where: "the boundary event timer on " + owner, expr: b.Delay}) + } + } + } + mappings := func(pms []ast.WorkflowParameterMappingNode, kind, callee string) { + for _, pm := range pms { + sites = append(sites, wfExprSite{ + where: fmt.Sprintf("%s %s, argument %s", kind, callee, pm.Parameter), + expr: pm.Expression, + }) + } + } + walkWorkflowActivities(acts, func(a ast.WorkflowActivityNode) { + switch n := a.(type) { + case *ast.WorkflowCallMicroflowNode: + mappings(n.ParameterMappings, "call microflow", n.Microflow.String()) + boundary(n.BoundaryEvents, "call microflow "+n.Microflow.String()) + case *ast.WorkflowCallWorkflowNode: + mappings(n.ParameterMappings, "call workflow", n.Workflow.String()) + case *ast.WorkflowDecisionNode: + sites = append(sites, wfExprSite{where: "decision " + workflowDecisionLabel(n), expr: n.Expression}) + case *ast.WorkflowWaitForTimerNode: + sites = append(sites, wfExprSite{where: "wait for timer", expr: n.DelayExpression}) + case *ast.WorkflowUserTaskNode: + label := workflowUserTaskLabel(n) + if n.DueDate != "" { + sites = append(sites, wfExprSite{where: "the due date of user task " + label, expr: n.DueDate}) + } + boundary(n.BoundaryEvents, "user task "+label) + case *ast.WorkflowWaitForNotificationNode: + boundary(n.BoundaryEvents, "wait for notification") + } + }) + return sites +} + +// ValidateWorkflowExpressionScope applies MDL-WF19 to a CREATE WORKFLOW. +func ValidateWorkflowExpressionScope(stmt *ast.CreateWorkflowStmt) []linter.Violation { + sites := workflowActivityExprSites(workflowStatementActivities(stmt)) + for _, esp := range stmt.EventSubProcesses { + if esp.Timer && esp.FirstExecutionTime != "" { + sites = append(sites, wfExprSite{where: "the timer start of event sub-process " + esp.Name, expr: esp.FirstExecutionTime}) + } + } + if stmt.DueDate != "" { + sites = append(sites, wfExprSite{where: "the workflow's due date", expr: stmt.DueDate, header: true}) + } + return checkWorkflowExprScope(sites, stmt.ParameterVar, workflowLocation(stmt.Name)) +} + +// alterWorkflowExprSites collects the expressions an ALTER WORKFLOW writes. +func alterWorkflowExprSites(stmt *ast.AlterWorkflowStmt) []wfExprSite { + sites := workflowActivityExprSites(alterWorkflowAddedActivities(stmt)) + for _, op := range stmt.Operations { + switch o := op.(type) { + case *ast.InsertBoundaryEventOp: + if o.Delay != "" { + sites = append(sites, wfExprSite{where: "the boundary event timer on " + o.ActivityRef, expr: o.Delay}) + } + case *ast.SetWorkflowPropertyOp: + if strings.EqualFold(o.Property, "due_date") && o.Value != "" { + sites = append(sites, wfExprSite{where: "the workflow's due date", expr: o.Value, header: true}) + } + case *ast.SetActivityPropertyOp: + if strings.EqualFold(o.Property, "due_date") && o.Value != "" { + sites = append(sites, wfExprSite{where: "the due date of " + o.ActivityRef, expr: o.Value}) + } + } + } + return sites +} + +// checkWorkflowExprScope reports each expression naming a variable outside the +// workflow scope, once per expression and name. declared is the header's +// context variable ("" in ALTER, which cannot see it). +func checkWorkflowExprScope(sites []wfExprSite, declared string, loc linter.Location) []linter.Violation { + alias := strings.TrimPrefix(declared, "$") + var out []linter.Violation + for _, s := range sites { + seen := map[string]bool{} + for _, name := range workflowExprVars(s.expr) { + if seen[name] { + continue + } + seen[name] = true + if strings.EqualFold(name, workflowContextVar) || (alias != "" && strings.EqualFold(name, alias)) { + continue + } + isInstance := strings.EqualFold(name, workflowInstanceVar) + if isInstance && !s.header { + continue + } + out = append(out, linter.Violation{ + RuleID: wfExprScopeRule, + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("%s uses $%s, which is not in scope in a workflow expression — MxBuild rejects it (CE0117 \"Error(s) in expression.\")", s.where, name), + Suggestion: workflowScopeSuggestion(name, s.header, isInstance), + }) + } + } + return out +} + +// workflowScopeSuggestion names the variable the author most likely meant. +func workflowScopeSuggestion(name string, header, isInstance bool) string { + const scope = "A workflow expression can name only the context, $WorkflowContext (or the name the header declares for it), and the running workflow, $WorkflowInstance (System.Workflow)." + switch { + case isInstance && header: + return "The workflow's own due date is evaluated before the instance exists: only $WorkflowContext is in scope there. Use [%CurrentDateTime%] or an attribute of the context, e.g. addDays([%CurrentDateTime%], 3)." + case strings.EqualFold(name, "Workflow"): + if header { + return "Only $WorkflowContext is in scope in the workflow's own due date." + } + return "The running workflow is $WorkflowInstance, not $Workflow — pass it to a System.Workflow parameter as `(Workflow = $WorkflowInstance)`. " + scope + case strings.EqualFold(name, "currentUser"), strings.EqualFold(name, "currentSession"): + return "A workflow runs without a user session, so $" + name + " is not available. Store the user on the context object, or resolve it in a microflow. " + scope + case strings.EqualFold(name, "WorkflowUserTask"): + return "$WorkflowUserTask is the user task PAGE's parameter; a workflow expression cannot see it. " + scope + } + return scope +} diff --git a/mdl/executor/validate_workflow_scope_test.go b/mdl/executor/validate_workflow_scope_test.go new file mode 100644 index 0000000000..530899d67b --- /dev/null +++ b/mdl/executor/validate_workflow_scope_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// wfScopeViolations returns the MDL-WF19 violations ValidateWorkflow reports for +// a workflow statement. +func wfScopeViolations(t *testing.T, src string) []linter.Violation { + t.Helper() + var out []linter.Violation + for _, v := range ValidateWorkflow(parseWorkflowStmt(t, src)) { + if v.RuleID == "MDL-WF19" { + out = append(out, v) + } + } + return out +} + +// MDL-WF19 (mendixlabs/mxcli#1529, #1523). A workflow expression can name only +// the context and the workflow instance. Every row below was measured with +// `mx check` on 11.6.6 and on 11.15.0 (identical verdicts): the "flagged" rows +// are CE0117 "Error(s) in expression.", the "clean" rows build with 0 errors. +// `check --references` and `exec` passed all of them before this rule. +func TestWorkflowExpressionScope(t *testing.T) { + const head = "create workflow M.W parameter $WorkflowContext: M.Item" + cases := []struct { + name string + src string + want string // substring of the message (or suggestion); "" = no violation + }{ + // --- flagged: CE0117 measured --- + {"call microflow argument $Workflow (#1529)", + head + " begin call microflow M.ACT_Bind(Workflow = $Workflow, Item = $WorkflowContext); end workflow;", + "$WorkflowInstance"}, + {"deprecated with() argument $Workflow (#1523)", + head + " begin call microflow M.ACT_Bind with (Workflow = '$Workflow'); end workflow;", + "$WorkflowInstance"}, + {"call workflow argument", + head + " begin call workflow M.Sub(WorkflowContext = $Workflow); end workflow;", + "$WorkflowInstance"}, + {"decision on $currentUser", + head + " begin decision d1 $currentUser != empty outcomes true -> { } false -> { }; end workflow;", + "$currentUser"}, + {"decision on $currentSession", + head + " begin decision d1 $currentSession != empty outcomes true -> { } false -> { }; end workflow;", + "$currentSession"}, + {"user task due date on $WorkflowUserTask", + head + " begin user task u1 'U' page M.P due date addDays($WorkflowUserTask/StartTime, 1) outcomes 'Done' { }; end workflow;", + "$WorkflowUserTask"}, + {"call microflow argument $WorkflowUserTask", + head + " begin call microflow M.ACT_Task(Task = $WorkflowUserTask); end workflow;", + "$WorkflowUserTask"}, + {"boundary timer on $WorkflowUserTask", + head + " begin user task u1 'U' page M.P outcomes 'Done' { } boundary event interrupting timer addDays($WorkflowUserTask/StartTime, 3) { }; end workflow;", + "$WorkflowUserTask"}, + {"wait for timer on $Workflow", + head + " begin wait for timer t1 addDays($Workflow/StartTime, 1); end workflow;", + "$WorkflowInstance"}, + {"event sub-process timer start on $Workflow", + head + " begin call microflow M.A(Item = $WorkflowContext); event subprocess E on non interrupting timer addDays($Workflow/StartTime, 1) as eStart { call microflow M.A(Item = $WorkflowContext); }; end workflow;", + "$WorkflowInstance"}, + {"nested in an outcome", + head + " begin decision d1 $WorkflowContext/Ok outcomes true -> { call microflow M.ACT_Inst(Workflow = $Workflow); } false -> { }; end workflow;", + "$WorkflowInstance"}, + {"workflow due date on $WorkflowInstance", + head + " due date addDays($WorkflowInstance/StartTime, 2) begin call microflow M.A(Item = $WorkflowContext); end workflow;", + "due date"}, + {"an undeclared name", + head + " begin call microflow M.A(Item = $Request); end workflow;", + "$Request"}, + + // --- clean: 0 errors measured --- + {"$WorkflowInstance as a call argument", + head + " begin call microflow M.ACT_Bind(Workflow = $WorkflowInstance, Item = $WorkflowContext); end workflow;", ""}, + {"$WorkflowInstance in a decision", + head + " begin decision d1 $WorkflowInstance/Name != empty outcomes true -> { } false -> { }; end workflow;", ""}, + {"$WorkflowInstance in a wait for timer", + head + " begin wait for timer t1 addDays($WorkflowInstance/StartTime, 1); end workflow;", ""}, + {"$WorkflowInstance in a user task due date", + head + " begin user task u1 'U' page M.P due date addDays($WorkflowInstance/StartTime, 1) outcomes 'Done' { }; end workflow;", ""}, + {"$WorkflowInstance in a boundary timer", + head + " begin user task u1 'U' page M.P outcomes 'Done' { } boundary event interrupting timer addDays($WorkflowInstance/StartTime, 3) { }; end workflow;", ""}, + {"$WorkflowInstance in an event sub-process", + head + " begin call microflow M.A(Item = $WorkflowContext); event subprocess E on non interrupting timer addDays($WorkflowInstance/StartTime, 1) as eStart { call microflow M.ACT_Inst(Workflow = $WorkflowInstance); }; end workflow;", ""}, + {"workflow due date on the context", + head + " due date addDays([%CurrentDateTime%], length($WorkflowContext/Title)) begin call microflow M.A(Item = $WorkflowContext); end workflow;", ""}, + // Rewritten to the exact spelling on write, so not an error. + {"$workflowContext lower case", + head + " begin call microflow M.A(Item = $workflowContext); end workflow;", ""}, + {"$workflowInstance lower case", + head + " begin call microflow M.ACT_Inst(Workflow = $workflowInstance); end workflow;", ""}, + {"declared header alias", + "create workflow M.W parameter $Ctx: M.Item begin decision d1 $Ctx/Title != empty outcomes true -> { } false -> { }; end workflow;", ""}, + {"header alias named Workflow", + "create workflow M.W parameter $Workflow: M.Item begin call microflow M.A(Item = $Workflow); end workflow;", ""}, + {"a $ inside a string literal", + head + " begin decision d1 $WorkflowContext/Title = '$Workflow isn''t $x' outcomes true -> { } false -> { }; end workflow;", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vs := wfScopeViolations(t, c.src) + if c.want == "" { + if len(vs) != 0 { + t.Fatalf("want no MDL-WF19, got %d: %s", len(vs), vs[0].Message) + } + return + } + if len(vs) != 1 { + t.Fatalf("want one MDL-WF19 (CE0117), got %d", len(vs)) + } + if vs[0].Severity != linter.SeverityError { + t.Errorf("severity = %v, want error", vs[0].Severity) + } + text := vs[0].Message + " " + vs[0].Suggestion + if !strings.Contains(text, c.want) { + t.Errorf("violation %q / %q does not mention %q", vs[0].Message, vs[0].Suggestion, c.want) + } + if !strings.Contains(vs[0].Message, "CE0117") { + t.Errorf("message %q does not name CE0117", vs[0].Message) + } + }) + } +} + +// ALTER WORKFLOW writes the same expressions — an inserted activity, a boundary +// event's timer, a due date — so the rule follows it there. +func TestWorkflowExpressionScope_Alter(t *testing.T) { + count := func(src string) int { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt, ok := prog.Statements[0].(*ast.AlterWorkflowStmt) + if !ok { + t.Fatalf("statement is %T", prog.Statements[0]) + } + n := 0 + for _, v := range ValidateAlterWorkflow(stmt) { + if v.RuleID == "MDL-WF19" { + n++ + } + } + return n + } + cases := []struct { + src string + want int + }{ + {"alter workflow M.W { insert after Review { call microflow M.ACT_Inst(Workflow = $Workflow); } };", 1}, + {"alter workflow M.W { insert after Review { call microflow M.ACT_Inst(Workflow = $WorkflowInstance); } };", 0}, + {"alter workflow M.W { set (DueDate: addDays($WorkflowInstance/StartTime, 7)); };", 1}, + {"alter workflow M.W { set (DueDate: addDays([%CurrentDateTime%], 7)); };", 0}, + } + for _, c := range cases { + if got := count(c.src); got != c.want { + t.Errorf("%s: %d MDL-WF19, want %d", c.src, got, c.want) + } + } +} From 71a599f2023607dea0bdae2d949cc7b75ab22cc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:22:22 +0000 Subject: [PATCH 12/25] fix(executor): an undefined glyph code is an error, MDL078 (mendixlabs/mxcli#1522) MDL078 reported a glyph code the Mendix glyph font does not define as a warning, so exec wrote it, mx check passed it at 0 errors, and `mxbuild --target=deploy` crashed: exit 1, InvalidOperationException "Sequence contains no matching element" at GlyphFont.GetClass, a layout named instead of the icon, and no --write-errors file for a build gate to read. A real model error in the same build exits 3 and writes the file. The warning hedged against the font table being a snapshot that Mendix might extend. That was measurable: mxbuild bundles its own modeler/glyphicons-halflings-regular.ttf, byte-identical in 9.24, 10.12, 11.6.6 and 11.15.0, and its cmap's private-use range is exactly the 247 codes in glyphIcons. MDL078 is now an error, so check fails and exec refuses before writing. Every carrier (navigation menu items, menu documents, page/snippet/layout widgets) goes through the same validator. A glyph has no name, so the suggestion names the defined glyphs either side of the bad code (57388 -> 57385 play-circle, 57392 repeat). navigation-glyph-code.mdl becomes a .fail.mdl, since check now rejects it. Measured with the reported code 57388: before, exec applied it, mx check 0 errors, mxbuild deploy exit 1 with no error file (11.15.0 and 11.6.6). After, check reports MDL078 as an error and exec prints "Refusing to execute ... Nothing was written". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...defined-glyph-mdl078-is-an-error-1522.json | 1 + .claude/skills/mendix/create-page/SKILL.md | 13 ++-- .../skills/mendix/manage-navigation/SKILL.md | 33 ++++----- cmd/mxcli/syntax/features_integration.go | 2 +- cmd/mxcli/syntax/features_page.go | 2 +- docs/01-project/MDL_QUICK_REFERENCE.md | 14 ++-- .../1522-undefined-glyph-is-an-error.fail.mdl | 37 ++++++++++ ...ode.mdl => navigation-glyph-code.fail.mdl} | 13 ++-- mdl/executor/glyph_icons.go | 5 +- mdl/executor/validate_glyph_codes.go | 69 +++++++++++++++---- mdl/executor/validate_glyph_codes_test.go | 64 +++++++++++++++++ 11 files changed, 203 insertions(+), 50 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-undefined-glyph-mdl078-is-an-error-1522.json create mode 100644 mdl-examples/bug-tests/1522-undefined-glyph-is-an-error.fail.mdl rename mdl-examples/bug-tests/{navigation-glyph-code.mdl => navigation-glyph-code.fail.mdl} (86%) diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-undefined-glyph-mdl078-is-an-error-1522.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-undefined-glyph-mdl078-is-an-error-1522.json new file mode 100644 index 0000000000..9c79ae4837 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-undefined-glyph-mdl078-is-an-error-1522.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`menu item 'A' (…, Icon: glyph 57388)` — a code the glyph font does not define — prints MDL078 as a warning, exec applies it, `mx check` reports 0 errors, and `mxbuild --target=deploy` exits 1 with System.InvalidOperationException 'Sequence contains no matching element' at GlyphFont.GetClass, naming a layout, and writes NO --write-errors file (a CE error exits 3 and writes one), so a build gate has nothing to report", "cause": "MDL078 was deliberately a warning: the glyph table was treated as a snapshot of a Mendix asset that might grow, so refusing was judged worse than letting the build fail", "file": "`mdl/executor/validate_glyph_codes.go` (`validateGlyphCodes` severity, new `glyphNeighbours`)", "insight": "The snapshot risk was measurable and nobody had measured it: mxbuild bundles its own modeler/glyphicons-halflings-regular.ttf, byte-identical (sha256 e3950440…57456) in 9.24, 10.12, 11.6.6 and 11.15.0, and its format-4 cmap private-use range is exactly the 247 codes in glyphIcons. A hedge against a change that has not happened in four major versions is not worth a crash with no error file. The control for 'no error file' is the same build with a real CE error: exit 3 and a populated errors JSON. A glyph has no name, so 'check the icon name' became naming the defined codes either side of the bad one (57385 play-circle / 57392 repeat for 57388). Every carrier (nav menu, menu document, page/snippet/layout widget) goes through iconRefsInStatement, so one severity change covers all of them; ALTER PAGE SET has no glyph spelling", "refs": ["mendixlabs/mxcli#1522", "ako/mxcli#1404", "PR #1075"], "ce": []} diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 012887b120..51f834dcf0 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -16,11 +16,11 @@ description: "CREATE PAGE syntax reference — parameters, variables, layouts, a | 197-267 | Basic Examples | | 268-289 | Modifying Existing Pages | | 290-393 | Conditional Visibility and Editability | -| 394-557 | Known Limitations | -| 558-566 | Tips | -| 567-593 | Related Commands | -| 594-618 | PLUGGABLEWIDGET Escape Hatch | -| 619-622 | See Also | +| 394-558 | Known Limitations | +| 559-567 | Tips | +| 568-594 | Related Commands | +| 595-619 | PLUGGABLEWIDGET Escape Hatch | +| 620-623 | See Also | # CREATE PAGE - MDL Syntax Guide @@ -489,7 +489,8 @@ lists them and `describe icon collection Atlas_Core.Atlas_Filled` lists the name A glyph has a code and no name. The codes are **sparse**, and an undefined one fails only at `mxbuild --target=deploy` — with *"An exception occurred while exporting page ''"*, naming the page and never the icon — so `mxcli check` -reports it as **MDL078** first. Browse them with `list glyphs`. +refuses it as **MDL078**, an error (`exec` writes nothing), naming the defined +glyphs either side of the code. Browse them with `list glyphs`. `describe page` emits all three forms, so describe → exec round-trips a button's icon whichever kind it is. diff --git a/.claude/skills/mendix/manage-navigation/SKILL.md b/.claude/skills/mendix/manage-navigation/SKILL.md index dc1794cb0d..3a4eb209de 100644 --- a/.claude/skills/mendix/manage-navigation/SKILL.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -13,15 +13,15 @@ description: "Inspect and change navigation profiles in MDL — home pages, menu | 31-40 | When to Use This Skill | | 41-59 | Navigation Concepts | | 60-77 | Show Commands (Read-Only) | -| 78-358 | CREATE OR MODIFY NAVIGATION (Full Replacement) | -| 359-380 | Round-Trip Workflow | -| 381-402 | Catalog Queries | -| 403-452 | Common Patterns | -| 453-504 | Menu Documents (standalone, reusable) | -| 505-575 | Offline Profiles | -| 576-590 | Checklist | -| 591-594 | Offline synchronization (`CREATE NAVIGATION … SYNC (…)`) | -| 595-597 | Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) | +| 78-359 | CREATE OR MODIFY NAVIGATION (Full Replacement) | +| 360-381 | Round-Trip Workflow | +| 382-403 | Catalog Queries | +| 404-453 | Common Patterns | +| 454-505 | Menu Documents (standalone, reusable) | +| 506-576 | Offline Profiles | +| 577-591 | Checklist | +| 592-595 | Offline synchronization (`CREATE NAVIGATION … SYNC (…)`) | +| 596-598 | Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) | # Navigation Management Skill @@ -179,13 +179,14 @@ The **bare** form is the icon-collection icon and is what you normally want. Use navigation` emits it for you — and `image` for a picture from an image collection, which is a different document from an icon collection. -**Do not invent a glyph code.** It is a bare integer that nothing resolves, so a -code the Mendix font does not define passes `mxcli check` AND `mx check` at 0 -errors and then breaks `mxbuild --target=deploy` with *"An exception occurred -while exporting layout ''"* — a message naming a document that is -not the cause. `mxcli check` now warns (**MDL078**) against the 247 codes the -shipped font defines, but a glyph is still an unchecked number where an icon -collection reference is a resolved model reference. Browse the codes with `list glyphs` +**Do not invent a glyph code.** It is a bare integer that `mx check` does not +resolve: a code the Mendix font does not define passes `mx check` at 0 errors and +then crashes `mxbuild --target=deploy` (exit 1, *"Sequence contains no matching +element"*, naming a layout that is not the cause, and no error file). +`mxcli check` refuses it as **MDL078**, an error, against the 247 codes the font +defines, and `exec` writes nothing (mendixlabs/mxcli#1522); the message names the +defined glyphs either side of the code. An icon collection reference is still the +better choice: it names the icon. Browse the codes with `list glyphs` (`list glyphs like 'star'` searches by name, `describe glyph 57350` goes the other way), or use `Icon: Atlas_Core.Atlas.` and list the names with `describe icon collection Atlas_Core.Atlas`. diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 8680b03bc2..7f02bfdc7a 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -766,7 +766,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "\n" + "-- Prefer an icon COLLECTION reference where the icon exists there: it is a\n" + "-- model reference that `check --references` resolves, while a glyph code is\n" + - "-- an unchecked integer until MDL078 sees it.", + "-- an integer that MDL078 checks against the font and refuses if undefined.", SeeAlso: []string{"icon-collection", "navigation.create"}, }) diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 2ad1840c1e..eabdc963d0 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -285,7 +285,7 @@ LIST IMPACT OF htmlelement; "confirmation", "confirm", "are you sure", "progress bar", "progress message", "disabled during execution", "disabled during action", "asynchronous", "form validations", }, - Syntax: "Action: NOTHING -- deliberately no action (Forms$NoAction)\nAction: SAVE CHANGES\nAction: SAVE CHANGES CLOSE PAGE -- save, then close the pop-up\nAction: CANCEL CHANGES\nAction: CANCEL CHANGES CLOSE PAGE\nAction: CLOSE PAGE\nAction: DELETE\nAction: DELETE CLOSE PAGE\nAction: CALL NANOFLOW Module.NF\nAction: CALL NANOFLOW Module.NF(Param = $val)\nAction: OPEN LINK 'https://example.com'\nAction: OPEN LINK $currentObject/URL -- address read from an attribute\nAction: SIGN OUT\nAction: COMPLETE TASK 'OutcomeName'\nAction: SHOW PAGE Module.Page\nAction: SHOW PAGE Module.Page(Param = $currentObject)\nAction: CALL MICROFLOW Module.MF\nAction: CALL MICROFLOW Module.MF(Param = $val)\nAction: CREATE OBJECT Module.Entity THEN SHOW PAGE Module.Page\n\nThe list above is exhaustive. Anything else in an action slot is an\nERROR: `Action: OPEN LINK` without a URL or `Action: SHOW PAGE` without a\npage is a syntax error, and a value that is not an action at all\n(or a snake-case keyword short its argument) is MDL-WIDGET28.\nSuch a widget used to be written with NO action at all and rendered as a\ndead control, with check, exec and mxbuild all clean, because a\nno-action widget is legal Mendix (mendixlabs/mxcli#1062). Write NOTHING\nwhen a control really is meant to be inert.\n\nThe actions are the words a microflow uses (R8). The snake-case spellings\n(SAVE_CHANGES, SHOW_PAGE, CLOSE_PAGE, DELETE_OBJECT, OPEN_LINK, SIGN_OUT, ...)\nand a flow call without CALL still parse, but are deprecated (MDL-DEPR020);\n`mxcli fmt --upgrade` rewrites them.\n\nThe same forms serve `OnClick:` (an alias of `Action:`) and `OnChange:`.\n\nSETTINGS. Any action but NOTHING takes a `with ( … )` list of the\nsettings Studio Pro shows under the event (ako/mxcli#721):\n\n DisabledDuringExecution: true|false -- every action; default true\n ProgressBar: None|NonBlocking|Blocking -- flow calls; default None\n ProgressMessage: 'text' -- flow calls\n Confirmation: 'question' -- flow calls: ask before running\n ProceedCaption: 'text', CancelCaption: 'text' -- its buttons; default Proceed / Cancel\n Asynchronous: true|false -- call microflow only; default false\n FormValidations: All|Widget|None -- call microflow only; default All\n\nAction: CALL MICROFLOW M.Delete(Order = $currentObject) with (\n ProgressBar: Blocking, ProgressMessage: 'Deleting…',\n Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')\n\nA setting left out keeps its default. DESCRIBE prints only the settings\nthat differ from it. An unknown key, or one the action does not have, is an\nerror. For CREATE OBJECT … THEN SHOW PAGE …, the list goes at the end and\nbelongs to the create.\n\nA microflow or nanoflow action is a CALL: it needs an argument for every\nparameter the flow declares, or Mendix rejects the page with CE1571. The\nargument list is the same on every widget that takes an action -- a\nCONTAINER (which is clickable) as much as an ACTIONBUTTON. An enclosing\ndata container of the right type supplies it without an argument; a data\ngrid's CONTROL BAR does not, because it is not row-scoped -- pass the\ngrid's selection there (`$dgOrders`).\n\nA SHOW PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\nThe exception is a parameter of the page or snippet itself: it is bound\nby name (Studio Pro stores an explicit mapping), so `(Item = $Item)` works\nanywhere, outside every data widget too (mendixlabs/mxcli#1403).\nOutside any data widget there is no context object to infer, so such a\nbutton takes no OTHER argument -- not $currentObject, not a literal.\nmxcli used to drop it in silence and mxbuild then reported CE1571 per\nparameter of the target page (mendixlabs/mxcli#1029). Route that\nnavigation through a microflow.\n\nOPEN LINK takes a web address, stored as a Forms$StaticOrDynamicString:\neither a literal, or $currentObject/Attr to read it from an attribute of\nthe enclosing data container's object at runtime (Studio Pro's \"Address:\nattribute\"). An address over an association path is not supported yet.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\n\nMendix has THREE icon elements and the keyword picks which one:\n\nIcon: 'Atlas_Core.Atlas_Filled.pencil' -- an icon collection\nIcon: image MyModule.Images.logo -- an IMAGE collection\nIcon: glyph 57377 -- a font code point\n\nThe bare form is the icon-collection icon and any collection in the\nproject works, third-party ones included. The image form points into a\ndifferent document, and is spelled the same way apart from the keyword\n-- write it without `image` and mxcli stores a custom-icon reference,\nwhich fails the build with CE1613 (mendixlabs/mxcli#1059).\n`mxcli check -p … --references` resolves each kind against its own\ncollection and names the remedy when the kind is wrong.\n\nA glyph carries a code and no name. Codes are sparse, and an undefined\none fails only at `mxbuild --target=deploy`, naming the PAGE rather\nthan the icon -- so MDL078 checks it against the font's own table.\nList them with `list glyphs`.\n\nA name may be quoted or bare; a hyphenated segment is double-quoted on\nits own: Atlas_Core.Atlas.\"align-center\".\n\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", + Syntax: "Action: NOTHING -- deliberately no action (Forms$NoAction)\nAction: SAVE CHANGES\nAction: SAVE CHANGES CLOSE PAGE -- save, then close the pop-up\nAction: CANCEL CHANGES\nAction: CANCEL CHANGES CLOSE PAGE\nAction: CLOSE PAGE\nAction: DELETE\nAction: DELETE CLOSE PAGE\nAction: CALL NANOFLOW Module.NF\nAction: CALL NANOFLOW Module.NF(Param = $val)\nAction: OPEN LINK 'https://example.com'\nAction: OPEN LINK $currentObject/URL -- address read from an attribute\nAction: SIGN OUT\nAction: COMPLETE TASK 'OutcomeName'\nAction: SHOW PAGE Module.Page\nAction: SHOW PAGE Module.Page(Param = $currentObject)\nAction: CALL MICROFLOW Module.MF\nAction: CALL MICROFLOW Module.MF(Param = $val)\nAction: CREATE OBJECT Module.Entity THEN SHOW PAGE Module.Page\n\nThe list above is exhaustive. Anything else in an action slot is an\nERROR: `Action: OPEN LINK` without a URL or `Action: SHOW PAGE` without a\npage is a syntax error, and a value that is not an action at all\n(or a snake-case keyword short its argument) is MDL-WIDGET28.\nSuch a widget used to be written with NO action at all and rendered as a\ndead control, with check, exec and mxbuild all clean, because a\nno-action widget is legal Mendix (mendixlabs/mxcli#1062). Write NOTHING\nwhen a control really is meant to be inert.\n\nThe actions are the words a microflow uses (R8). The snake-case spellings\n(SAVE_CHANGES, SHOW_PAGE, CLOSE_PAGE, DELETE_OBJECT, OPEN_LINK, SIGN_OUT, ...)\nand a flow call without CALL still parse, but are deprecated (MDL-DEPR020);\n`mxcli fmt --upgrade` rewrites them.\n\nThe same forms serve `OnClick:` (an alias of `Action:`) and `OnChange:`.\n\nSETTINGS. Any action but NOTHING takes a `with ( … )` list of the\nsettings Studio Pro shows under the event (ako/mxcli#721):\n\n DisabledDuringExecution: true|false -- every action; default true\n ProgressBar: None|NonBlocking|Blocking -- flow calls; default None\n ProgressMessage: 'text' -- flow calls\n Confirmation: 'question' -- flow calls: ask before running\n ProceedCaption: 'text', CancelCaption: 'text' -- its buttons; default Proceed / Cancel\n Asynchronous: true|false -- call microflow only; default false\n FormValidations: All|Widget|None -- call microflow only; default All\n\nAction: CALL MICROFLOW M.Delete(Order = $currentObject) with (\n ProgressBar: Blocking, ProgressMessage: 'Deleting…',\n Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')\n\nA setting left out keeps its default. DESCRIBE prints only the settings\nthat differ from it. An unknown key, or one the action does not have, is an\nerror. For CREATE OBJECT … THEN SHOW PAGE …, the list goes at the end and\nbelongs to the create.\n\nA microflow or nanoflow action is a CALL: it needs an argument for every\nparameter the flow declares, or Mendix rejects the page with CE1571. The\nargument list is the same on every widget that takes an action -- a\nCONTAINER (which is clickable) as much as an ACTIONBUTTON. An enclosing\ndata container of the right type supplies it without an argument; a data\ngrid's CONTROL BAR does not, because it is not row-scoped -- pass the\ngrid's selection there (`$dgOrders`).\n\nA SHOW PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\nThe exception is a parameter of the page or snippet itself: it is bound\nby name (Studio Pro stores an explicit mapping), so `(Item = $Item)` works\nanywhere, outside every data widget too (mendixlabs/mxcli#1403).\nOutside any data widget there is no context object to infer, so such a\nbutton takes no OTHER argument -- not $currentObject, not a literal.\nmxcli used to drop it in silence and mxbuild then reported CE1571 per\nparameter of the target page (mendixlabs/mxcli#1029). Route that\nnavigation through a microflow.\n\nOPEN LINK takes a web address, stored as a Forms$StaticOrDynamicString:\neither a literal, or $currentObject/Attr to read it from an attribute of\nthe enclosing data container's object at runtime (Studio Pro's \"Address:\nattribute\"). An address over an association path is not supported yet.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\n\nMendix has THREE icon elements and the keyword picks which one:\n\nIcon: 'Atlas_Core.Atlas_Filled.pencil' -- an icon collection\nIcon: image MyModule.Images.logo -- an IMAGE collection\nIcon: glyph 57377 -- a font code point\n\nThe bare form is the icon-collection icon and any collection in the\nproject works, third-party ones included. The image form points into a\ndifferent document, and is spelled the same way apart from the keyword\n-- write it without `image` and mxcli stores a custom-icon reference,\nwhich fails the build with CE1613 (mendixlabs/mxcli#1059).\n`mxcli check -p … --references` resolves each kind against its own\ncollection and names the remedy when the kind is wrong.\n\nA glyph carries a code and no name. Codes are sparse, and an undefined\none fails only at `mxbuild --target=deploy`, naming the PAGE rather\nthan the icon -- so MDL078 refuses it (an error) against the font's own\ntable, naming the defined glyphs either side of the code.\nList them with `list glyphs`.\n\nA name may be quoted or bare; a hyphenated segment is double-quoted on\nits own: Atlas_Core.Atlas.\"align-center\".\n\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", Example: "ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\nACTIONBUTTON btnEdit (Caption: 'Edit',\n Action: SHOW PAGE Module.EditPage(Item = $currentObject))\nLINKBUTTON btnDelete (Caption: 'Delete', Action: DELETE,\n Icon: 'Atlas_Core.Atlas_Filled.pencil')\n\n-- A clickable CONTAINER in a data grid's control bar, calling a nanoflow\n-- with the grid's selection as its argument.\nDATAGRID dgOrders (DataSource: DATABASE FROM Sales.Order, Selection: Single) {\n COLUMN (Attribute: Number, Caption: 'Order #')\n CONTROLBAR {\n CONTAINER cShip (Class: 'command',\n Action: CALL NANOFLOW Sales.ACT_Ship(Order = $dgOrders)) {\n ACTIONBUTTON btnShip (Caption: 'Ship')\n }\n }\n}", SeeAlso: []string{"page.widgets"}, }) diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 0aba7bb301..3d641fa322 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -980,13 +980,15 @@ describe glyph 57350; -- by code describe glyph 'star'; -- or by name ``` -**A glyph code the font does not define is reported (MDL078, a warning).** A -glyph code is a bare integer, so nothing resolves it: `mxcli check` and `mx check` -both pass at 0 errors and the failure lands at `mxbuild --target=deploy`, as +**A glyph code the font does not define is refused (MDL078, an error).** A +glyph code is a bare integer that `mx check` does not resolve: it passes at 0 +errors and `mxbuild --target=deploy` crashes with exit 1, no error file, and *"An exception occurred while exporting layout ''"* — naming a -document that is not the cause. Measured on 11.14.0: mxbuild resolves the code -through a LINQ `.First(...)` in `GlyphFont.GetClass`, which throws on an absent -one. The rule checks the 247 codes the shipped font actually defines. Prefer an +document that is not the cause. mxbuild resolves the code through a LINQ +`.First(...)` in `GlyphFont.GetClass`, which throws on an absent one (measured on +11.6.6, 11.14.0 and 11.15.0). The rule checks the 247 codes of mxbuild's own +glyph font, which is byte-identical from 9.24 to 11.15, so `check` fails and +`exec` writes nothing (mendixlabs/mxcli#1522). Prefer an icon collection reference, which `check --references` resolves before anything is written. diff --git a/mdl-examples/bug-tests/1522-undefined-glyph-is-an-error.fail.mdl b/mdl-examples/bug-tests/1522-undefined-glyph-is-an-error.fail.mdl new file mode 100644 index 0000000000..bdb3f92c59 --- /dev/null +++ b/mdl-examples/bug-tests/1522-undefined-glyph-is-an-error.fail.mdl @@ -0,0 +1,37 @@ +mdl 1; +-- ============================================================================ +-- Bug #1522 (part a) — an undefined glyph code was a WARNING (MDL078), so +-- `exec` wrote it, `mx check` passed it at 0 errors, and +-- `mxbuild --target=deploy` crashed: exit 1, "System.InvalidOperationException: +-- Sequence contains no matching element at ...GlyphFont.GetClass(Int32)", and +-- no error file for a build gate to read. +-- +-- 57388 (0xE02C) is in a gap of the font: the defined codes either side are +-- 57385 play-circle and 57392 repeat. mxbuild's font +-- (modeler/glyphicons-halflings-regular.ttf, byte-identical in 9.24, 10.12, +-- 11.6.6 and 11.15.0) defines exactly the 247 codes `list glyphs` shows. +-- +-- MDL078 is now an ERROR: `check` fails and `exec` refuses before writing. +-- +-- mxcli check this-file -> MDL078 error naming glyph 57388 +-- mxcli exec this-file -p app.mpr -> "Refusing to execute", nothing written +-- before: check passed with a warning; exec applied it; mx check 0 errors; +-- mxbuild --target=deploy exit 1, no error file +-- +-- A .fail.mdl: `make check-mdl` requires `check` to reject it. +-- ============================================================================ + +create module Issue1522Glyph; + +create page Issue1522Glyph.Home ( + Title: 'Home', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext txtHome (Content: 'Home') +}; + +create or modify navigation Responsive + home page Issue1522Glyph.Home +{ + menu item 'A' ( OnClick: show page Issue1522Glyph.Home, Icon: glyph 57388 ) +}; diff --git a/mdl-examples/bug-tests/navigation-glyph-code.mdl b/mdl-examples/bug-tests/navigation-glyph-code.fail.mdl similarity index 86% rename from mdl-examples/bug-tests/navigation-glyph-code.mdl rename to mdl-examples/bug-tests/navigation-glyph-code.fail.mdl index 78ef3f1a37..0eeb87c16c 100644 --- a/mdl-examples/bug-tests/navigation-glyph-code.mdl +++ b/mdl-examples/bug-tests/navigation-glyph-code.fail.mdl @@ -26,9 +26,12 @@ mdl 1; -- of the font Atlas_Core ships (glyphicons-halflings-regular.woff), 247 codes in -- 35 runs, which agrees with mxbuild on both measured points. -- --- A WARNING, not an error. The table is a snapshot of a Mendix asset, and if --- Mendix ever extends the font then refusing a newly-valid code would be worse --- than the gap this closes. +-- AN ERROR since mendixlabs/mxcli#1522 (it was a warning): exec applied the +-- code and mxbuild then crashed with exit 1 and no error file. mxbuild's own +-- font (modeler/glyphicons-halflings-regular.ttf) is byte-identical in 9.24, +-- 10.12, 11.6.6 and 11.15.0 and defines exactly these 247 codes, so the +-- "snapshot" the warning hedged against has not moved. This file is therefore a +-- negative test (.fail.mdl): `check` must reject it. -- -- THE BETTER SPELLING is an icon collection reference, which is a model -- reference and is resolved by `mxcli check --references` before anything is @@ -50,8 +53,8 @@ mdl 1; -- -- Verify: -- --- mxcli check navigation-glyph-code.mdl --- -- one MDL078 warning, for GlyphNav.PG_Bad's item only +-- mxcli check navigation-glyph-code.fail.mdl +-- -- one MDL078 error, for GlyphNav.MENU_Bad's item only -- -- Then perturb: -- diff --git a/mdl/executor/glyph_icons.go b/mdl/executor/glyph_icons.go index 9a9988ae23..e2452c2546 100644 --- a/mdl/executor/glyph_icons.go +++ b/mdl/executor/glyph_icons.go @@ -21,7 +21,10 @@ type GlyphIcon struct { // Both halves come from assets Mendix ships rather than from a hand-written // list. The CODES are the private-use range of the cmap in Atlas_Core's // glyphicons-halflings-regular.woff — the same table mxbuild resolves against, -// verified on two full deploy builds (see validateGlyphCodes). The NAMES +// verified on two full deploy builds (see validateGlyphCodes) and against the +// cmap of mxbuild's own modeler/glyphicons-halflings-regular.ttf, which is +// byte-identical in 9.24, 10.12, 11.6.6 and 11.15.0 and holds exactly these +// codes (mendixlabs/mxcli#1522). The NAMES // are the `.glyphicon-:before { content: "\eXXX" }` rules in Atlas_Core's // bundled Bootstrap stylesheet, which covers all 247 with none left over. // diff --git a/mdl/executor/validate_glyph_codes.go b/mdl/executor/validate_glyph_codes.go index 4610ac1a35..79aeec8d2e 100644 --- a/mdl/executor/validate_glyph_codes.go +++ b/mdl/executor/validate_glyph_codes.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "sort" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/linter" @@ -45,11 +46,29 @@ import ( // exactly that stack, and 57377 (0xE021, in the font) exports pages and layouts // cleanly. The font's cmap and mxbuild's table agree on both points. // -// # A warning, not an error +// # An error, not a warning (mendixlabs/mxcli#1522) // -// The table is a snapshot of a Mendix asset. If Mendix ever extends the font, -// a correct code would be reported here, and refusing it would be worse than the -// gap this closes — so `exec` still writes it and the author still gets told. +// This rule was a warning, on the reasoning that the table is a snapshot of a +// Mendix asset and a font Mendix later extended would make a correct code look +// wrong. The cost of that caution turned out to be the larger one: `exec` +// wrote the code, `mx check` passed it at 0 errors, and `mxbuild +// --target=deploy` died on the unhandled InvalidOperationException with exit 1 +// and NO error file — so a build gate that reads the error file had nothing to +// report, and the author had a stack trace naming the wrong document. +// +// The snapshot risk is measured, not assumed. mxbuild does not read the font +// Atlas_Core ships; it bundles its own `modeler/glyphicons-halflings-regular.ttf`, +// and that file is byte-identical (sha256 e3950440…57456) in mxbuild 9.24, 10.12, +// 11.6.6 and 11.15.0. Its cmap's private-use range is exactly the 247 codes in +// glyphIcons, none more and none fewer, and the two measured builds (11.14.0) +// agree with it on both points. The font has been frozen for the whole span +// mxcli supports, so refusing a code outside it refuses a build crash, not a +// valid icon. If a later Mendix ever does extend it, glyphIcons is the one +// table to update, and `--no-check` is the escape hatch meanwhile. +// +// Every carrier goes through this one rule — navigation menu items, menu +// documents and page/snippet/layout widgets, via iconRefsInStatement — so they +// all refuse alike. // // # It needs no project // @@ -72,19 +91,41 @@ func validateGlyphCodes(stmt ast.Statement) []linter.Violation { } out = append(out, linter.Violation{ RuleID: "MDL078", - Severity: linter.SeverityWarning, + Severity: linter.SeverityError, Message: fmt.Sprintf( - "%s uses glyph %d, which the Mendix glyph font does not define — the build fails "+ - "at `mxbuild --target=deploy` with \"An exception occurred while exporting "+ - " ''\" and \"Sequence contains no matching element\", "+ - "which names the document the icon is IN and never the icon", + "%s uses glyph %d, which the Mendix glyph font does not define — "+ + "`mxbuild --target=deploy` crashes on it (\"Sequence contains no matching "+ + "element\", exit 1, no error file) while `mx check` passes", ref.where, ref.code), - Suggestion: "prefer an icon collection reference, which IS resolved before anything " + - "is written: `Atlas_Core.Atlas.` (list them with " + - "`describe icon collection Atlas_Core.Atlas`). To keep a glyph, pick a code from " + - "`show glyphs` — the font's codes are sparse, so nearby numbers are usually " + - "not defined either.", + Suggestion: "check the icon: " + glyphNeighbours(ref.code) + + "`list glyphs like ''` finds a glyph by name. Better, use an icon collection " + + "reference, which is resolved before anything is written: `Atlas_Core.Atlas.` " + + "(list them with `describe icon collection Atlas_Core.Atlas`).", }) } return out } + +// glyphNeighbours names the defined glyphs either side of an undefined code, +// e.g. "the nearest defined glyphs are 57385 (play-circle) and 57392 (repeat); ". +// The codes are sparse, so "a nearby number" is usually undefined too; naming +// real neighbours is the useful version of that hint. +func glyphNeighbours(code int) string { + i := sort.Search(len(glyphIcons), func(i int) bool { return glyphIcons[i].Code >= code }) + var near []string + if i > 0 { + g := glyphIcons[i-1] + near = append(near, fmt.Sprintf("%d (%s)", g.Code, g.Name)) + } + if i < len(glyphIcons) { + g := glyphIcons[i] + near = append(near, fmt.Sprintf("%d (%s)", g.Code, g.Name)) + } + switch len(near) { + case 2: + return "the nearest defined glyphs are " + near[0] + " and " + near[1] + "; " + case 1: + return "the nearest defined glyph is " + near[0] + "; " + } + return "" +} diff --git a/mdl/executor/validate_glyph_codes_test.go b/mdl/executor/validate_glyph_codes_test.go index 923bdffc53..3ecb679f9f 100644 --- a/mdl/executor/validate_glyph_codes_test.go +++ b/mdl/executor/validate_glyph_codes_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" "github.com/mendixlabs/mxcli/mdl/types" ) @@ -208,3 +209,66 @@ func TestMDL078_ReachesAWidgetGlyph(t *testing.T) { t.Errorf("RuleID = %q, want MDL078", v[0].RuleID) } } + +// mendixlabs/mxcli#1522: MDL078 is an ERROR, so `check` fails and `exec` +// refuses the script before anything is written. As a warning, exec applied it +// and `mxbuild --target=deploy` then died with an unhandled +// InvalidOperationException ("Sequence contains no matching element", exit 1) +// and wrote no error file, while `mx check` passed -- so a build gate had +// nothing to report. +func TestMDL078_IsAnError(t *testing.T) { + v := validateGlyphCodes(glyphStmt(57388)) // the issue's code + if len(v) != 1 { + t.Fatalf("got %d violations, want 1", len(v)) + } + if v[0].Severity != linter.SeverityError { + t.Errorf("MDL078 severity = %v, want error -- exec only refuses on errors", v[0].Severity) + } + if !strings.Contains(v[0].Message, "57388") || !strings.Contains(v[0].Message, "Overview") { + t.Errorf("the message does not name the glyph and where it is: %s", v[0].Message) + } + // The way out names real glyphs: the defined codes either side of it. + // 57388 (0xE02C) sits in the gap between 57385 play-circle and 57392 repeat. + for _, want := range []string{"57385 (play-circle)", "57392 (repeat)", "list glyphs"} { + if !strings.Contains(v[0].Suggestion, want) { + t.Errorf("the suggestion does not mention %q: %s", want, v[0].Suggestion) + } + } +} + +// Past the font's last icon there is no code above to offer, only below. +func TestMDL078_SuggestsOnlyTheNeighboursThatExist(t *testing.T) { + v := validateGlyphCodes(glyphStmt(70000)) + if len(v) != 1 { + t.Fatalf("got %d violations, want 1", len(v)) + } + if !strings.Contains(v[0].Suggestion, "63743") { + t.Errorf("the suggestion does not offer the font's last code: %s", v[0].Suggestion) + } +} + +// The gate exec runs is ValidateProgram: a script with an undefined glyph on a +// menu item, a menu document or a page widget reports MDL078 as an error there, +// which is what makes exec refuse it. +func TestValidateProgram_UndefinedGlyphRefusesExec(t *testing.T) { + for name, src := range map[string]string{ + "navigation": `create or modify navigation Responsive + home page M.P +{ menu item 'A' ( OnClick: show page M.P, Icon: glyph 57388 ) };`, + "menu document": `create or modify menu M.Menu { + menu item 'A' ( OnClick: show page M.P, Icon: glyph 57388 ) +};`, + "page widget": `create page M.Q (title: 'Q', layout: Atlas_Core.Atlas_Default) +{ actionbutton btn (caption: 'Go', action: nothing, icon: glyph 57388) };`} { + t.Run(name, func(t *testing.T) { + got := ruleSeverities(ValidateProgram(build(t, src), "")) + sev, ok := got["MDL078"] + if !ok { + t.Fatalf("no MDL078; got %v", got) + } + if sev != linter.SeverityError { + t.Errorf("MDL078 severity = %v, want error", sev) + } + }) + } +} From 399083b216fc3b5bc6d3099528b2a4b5b76ad373 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:24:30 +0000 Subject: [PATCH 13/25] fix(pages): reset hidden column properties after an ALTER SET (mendixlabs/mxcli#1519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set (ColumnWidth: manual, Size: 220) on dg column(Name)` followed by `set (ColumnWidth: autoFill) …` stored width=autoFill with size=220, and mx check reported CE0463 on the Data grid 2. Cause: setColumnPropertyMut writes exactly the property named. Data grid 2 hides `size` unless width is "manual" (and `minWidth` unless width is "autoFit", `minWidthLimit` unless minWidth is "manual"), and a hidden property holding anything but its default is CE0463. The create path writes hidden properties at their default; ALTER never re-evaluated them. Fix: after a column SET the executor hands the column's visibility rules — lifted from the installed Data grid 2 package's editorConfig, not a list in mxcli — to the mutator (optional backend.ColumnHiddenPropertyResetter). pagemutator resets every primitive column property a rule now hides to the default the stored Type declares, repeating until nothing changes, so a reset that hides a further property resets that one too. A hidden property the same statement names with a non-default value is refused instead of silently overwritten (`ColumnWidth: autoFill, Size: 220`); its default (`Size: 1`) is accepted. check judges a column SET as a whole as well as per property, applying the same follow-up. Measured (fixture copies, create → manual/220 → autoFill, then mx check): 11.6.6 and 11.15.0 both 1 CE0463 before, 0 after; size stored 1 after the switch and still 220 under manual. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...-set-leaves-hidden-dependent-property.json | 1 + .../mendix/create-page/reference/widgets.md | 2 +- .../1519-datagrid-column-width-switch.mdl | 31 +++ mdl/backend/mutation.go | 12 + .../pagemutator/column_hidden_reset.go | 206 ++++++++++++++++++ .../pagemutator/column_hidden_reset_test.go | 129 +++++++++++ mdl/executor/cmd_alter_page.go | 39 ++++ .../cmd_alter_page_column_hidden_test.go | 80 +++++++ mdl/executor/validate_alter_set.go | 14 +- 9 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-16-column-set-leaves-hidden-dependent-property.json create mode 100644 mdl-examples/bug-tests/1519-datagrid-column-width-switch.mdl create mode 100644 mdl/backend/pagemutator/column_hidden_reset.go create mode 100644 mdl/backend/pagemutator/column_hidden_reset_test.go create mode 100644 mdl/executor/cmd_alter_page_column_hidden_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-column-set-leaves-hidden-dependent-property.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-column-set-leaves-hidden-dependent-property.json new file mode 100644 index 0000000000..18c73f5f25 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-16-column-set-leaves-hidden-dependent-property.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-16", "symptom": "`alter page … set (ColumnWidth: manual, Size: 220) on dg column(Name)` then `set (ColumnWidth: autoFill) …` stored width=autoFill with size=220 and mx check reported CE0463 on the Data grid 2 (11.6.6 and 11.15.0, 1 error; the reporter's workaround was `autoFill` with `Size: 1`) (mendixlabs/mxcli#1519 item 3b)", "cause": "`setColumnPropertyMut` (mdl/backend/pagemutator/mutator.go) writes exactly the property named and leaves the properties the widget hides BECAUSE of it at whatever they held. Data grid 2 3.x hides `size` unless width is \"manual\", `minWidth` unless width is \"autoFit\", `minWidthLimit` unless minWidth is \"manual\"; a hidden property holding a non-default value is CE0463. The create path writes them at their default; ALTER never re-evaluated", "file": "`mdl/backend/pagemutator/column_hidden_reset.go` (`ResetHiddenColumnProperties`, `resetHiddenItemProps` — defaults from the STORED Type, iterated to a fixpoint for cascades), `mdl/backend/mutation.go` (optional `backend.ColumnHiddenPropertyResetter`), `mdl/executor/cmd_alter_page.go` (`applySetPropertyMutator` → `resetHiddenColumnProperties` with rules lifted from the installed .mpk), `mdl/executor/validate_alter_set.go` (check judges a column SET whole as well as per property)", "insight": "**A setter that changes one property must re-ask which OTHER properties are now hidden** — the dependency is the widget's (editorConfig `hidePropertyIn(…,\"columns\",o,\"size\")`, lifted as a nested item-scope rule), so take the rules from the installed package and the defaults from the stored Type rather than special-casing `size`. A hidden property the same statement names is refused rather than silently reset (`ColumnWidth: autoFill, Size: 220`); its default (`Size: 1`) is accepted. Check's per-property dry run had to stop applying the follow-up: judged alone, `Size: 220` of `ColumnWidth: manual, Size: 220` is hidden under the stored autoFill and was refused — the first measurement run caught that, the unit tests did not. Test the whole-statement probe as well as the per-property one.", "refs": ["mendixlabs/mxcli#1519", "ako/mxcli#1401", "mendixlabs/mxcli#891"], "ce": ["CE0463"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 74834c9517..7c18f4c4f6 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -329,7 +329,7 @@ datagrid gridName ( | `Draggable` | `true`, `false` | `true` | Can reorder column | | `Hidable` | `yes`, `hidden`, `no` | `yes` | Can hide column | | `ColumnWidth` | `autofill`, `autoFit`, `manual` | `autofill` | Column width mode | -| `Size` | integer (px) | `1` | Width in pixels (when `ColumnWidth: manual`) | +| `Size` | integer (px) | `1` | Width in pixels — only under `ColumnWidth: manual`. Any other width hides it, so it must stay `1` (CE0463 otherwise): an `ALTER PAGE … SET (ColumnWidth: autoFill)` puts it back at `1` itself, and `SET (ColumnWidth: autoFill, Size: 220)` is refused | | `visible` | expression string | `true` | Column-level visibility — hides/shows the whole column, so use page variables, NOT `$currentObject` (per-object widget visibility is different — see "Conditional Visibility and Editability") | | `DynamicCellClass` | expression string | (empty) | Dynamic CSS class per cell | | `tooltip` | text string | (empty) | Cell tooltip text | diff --git a/mdl-examples/bug-tests/1519-datagrid-column-width-switch.mdl b/mdl-examples/bug-tests/1519-datagrid-column-width-switch.mdl new file mode 100644 index 0000000000..420ae7fe36 --- /dev/null +++ b/mdl-examples/bug-tests/1519-datagrid-column-width-switch.mdl @@ -0,0 +1,31 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1519 (item 3b) — switching a column's width back +-- ============================================================================ +-- +-- `set (ColumnWidth: manual, Size: 220)` then `set (ColumnWidth: autoFill)` +-- stored width=autoFill with size=220. Data grid 2 hides `size` unless the +-- width is manual, and a hidden property holding anything but its default is +-- CE0463 (measured on 11.6.6 and 11.15.0: 1 error before, 0 after). +-- +-- Expected now: after the second SET the column stores size=1 (the declared +-- default) and `mx check` reports 0 errors. Run against a project (-p): the +-- rules are lifted from the installed Data grid 2 package. +-- +-- Setting a hidden size in the same statement is refused instead, by check and +-- exec: set (ColumnWidth: autoFill, Size: 220) on dg column(Name); + +create module Bug1519b; +create persistent entity Bug1519b.Item ( Name: String(100), Code: String(20) ); + +create page Bug1519b.Item_Overview ( Title: 'Items', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg ( DataSource: database from Bug1519b.Item ) { + column (Attribute: Name, Caption: 'Name') + column (Attribute: Code, Caption: 'Code') + } +}; + +alter page Bug1519b.Item_Overview { set (ColumnWidth: manual, Size: 220) on dg column(Name); }; +alter page Bug1519b.Item_Overview { set (ColumnWidth: autoFill) on dg column(Name); }; + +describe page Bug1519b.Item_Overview; diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index bb92e08e12..954b16d22c 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -459,6 +459,18 @@ type WidgetPropertyDeclarer interface { DeclaresProperty(propertyKey string) bool } +// ColumnHiddenPropertyResetter is implemented by a PageMutator that can bring a +// Data grid 2 column back in line with its widget's visibility rules after a +// SET: every primitive property a rule now hides goes back to the default the +// stored Type declares (`size` once `width` leaves "manual" — CE0463 otherwise, +// mendixlabs/mxcli#1519). setProps are the MDL names the statement set; a +// hidden one holding a non-default value is refused rather than reset. Returns +// the schema keys reset. Optional: the Studio Pro MCP backend hands property +// sets to Studio Pro, which applies the widget's rules itself. +type ColumnHiddenPropertyResetter interface { + ResetHiddenColumnProperties(gridRef, columnRef string, rules []types.WidgetVisibilityRule, setProps []string) ([]string, error) +} + // ObjectListItemSpec describes one item of an object-list property (e.g. one // Accordion group, one PopupMenu basicItem, one Maps marker). The backend // applies these specs to the template's nested PropertyTypeIDs to produce diff --git a/mdl/backend/pagemutator/column_hidden_reset.go b/mdl/backend/pagemutator/column_hidden_reset.go new file mode 100644 index 0000000000..9ae80027b4 --- /dev/null +++ b/mdl/backend/pagemutator/column_hidden_reset.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "fmt" + "sort" + "strings" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// A column property the widget hides must hold its declared default. +// +// `set (ColumnWidth: manual, Size: 220)` and then `set (ColumnWidth: autoFill)` +// left `size` at 220. Data grid 2 hides `size` unless `width` is "manual" +// (editorConfig: `"manual"!==n.width&&hidePropertyIn(…,"columns",o,"size")`), +// and a hidden property holding anything but its default is CE0463 (measured on +// 11.6.6 and 11.15.0, mendixlabs/mxcli#1519). The create path writes hidden +// properties at their default; the ALTER setter changed one property and left +// the ones that depend on it alone. +// +// Which properties depend on which is the WIDGET's to say, so the rules are the +// editorConfig's own (lifted by the caller from the installed .mpk), and the +// defaults are the ones the stored Type declares — nothing here names `size`. + +// ResetHiddenColumnProperties brings a grid column back in line with its +// widget's visibility rules after a SET: every primitive property a rule now +// hides is reset to its declared default. A property the statement itself named +// (setProps, by MDL name) is never silently reset — holding a non-default value +// while hidden, it is refused instead, as MDL-WIDGET10 refuses it on create. +// +// It returns the schema keys it reset. Rules it cannot decide (a condition on a +// property it cannot read) change nothing. +func (m *Mutator) ResetHiddenColumnProperties(gridRef, columnRef string, rules []types.WidgetVisibilityRule, setProps []string) ([]string, error) { + col, err := findBsonColumn(m.rawData, gridRef, columnRef, m.widgetFinder) + if err != nil { + return nil, err + } + grid := m.widgetFinder(m.rawData, gridRef) + if grid == nil { + return nil, fmt.Errorf("widget %q not found", gridRef) + } + listKey := types.DataGridColumnsKey + defaults := columnPropDefaults(grid.widget, listKey) + explicit := make(map[string]bool, len(setProps)) + for _, p := range setProps { + if key := resolveColumnPropertyKey(p, col.colPropKeys); key != "" { + explicit[key] = true + } + } + return resetHiddenItemProps(col.widget, col.colPropKeys, col.colPropKinds, defaults, + primitiveValuesByKey(grid.widget, buildPropKeyMap(grid.widget)), listKey, rules, explicit) +} + +// resetHiddenItemProps is the document-level half, separate so it can be tested +// without a page. itemKeys/itemKinds map TypePointer ids to schema keys and +// value kinds; defaults and widgetValues are keyed by schema key. +func resetHiddenItemProps(item bson.D, itemKeys, itemKinds, defaults, widgetValues map[string]string, + listKey string, rules []types.WidgetVisibilityRule, explicit map[string]bool) ([]string, error) { + reset := map[string]bool{} + // A reset can hide another property in turn (`minWidth` back to "auto" + // hides `minWidthLimit`), so repeat until nothing changes. Each pass only + // ever moves a value TO its default, so this terminates. + for pass := 0; pass < len(rules)+1; pass++ { + values := primitiveValuesByKey(item, itemKeys) + changed := false + for _, rule := range rules { + if rule.ListPropertyKey != listKey || rule.HiddenWhen == nil { + continue + } + fires, determinable := rule.Fires(func(c types.WidgetVisibilityCondition) (string, bool) { + src := widgetValues + if c.Scope == types.ConditionScopeItem { + src = values + } + v, ok := src[c.PropertyKey] + return v, ok + }) + if !determinable || !fires { + continue + } + key := rule.PropertyKey + def, hasDefault := defaults[key] + cur, isPrimitive := values[key] + if !hasDefault || !isPrimitive || cur == def { + continue + } + if explicit[key] { + return nil, fmt.Errorf( + "column property %q is hidden under this column's configuration (%s), so it must keep its default %q — "+ + "a hidden property with another value fails the build with CE0463", + key, describeConditions(rule), def) + } + if !setPrimitiveByKey(item, itemKeys, itemKinds, key, def) { + continue + } + reset[key] = true + changed = true + } + if !changed { + break + } + } + out := make([]string, 0, len(reset)) + for k := range reset { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +func describeConditions(rule types.WidgetVisibilityRule) string { + parts := []string{} + for _, c := range rule.Conditions() { + switch c.Operator { + case "eq": + parts = append(parts, fmt.Sprintf("%s is %q", c.PropertyKey, c.Value)) + case "ne": + parts = append(parts, fmt.Sprintf("%s is not %q", c.PropertyKey, c.Value)) + default: + parts = append(parts, fmt.Sprintf("%s %s %s", c.PropertyKey, c.Operator, c.Value)) + } + } + return strings.Join(parts, " and ") +} + +// primitiveValuesByKey reads every primitive-valued property of a widget object +// (or of the widget's Object), keyed by schema key. +func primitiveValuesByKey(doc bson.D, keys map[string]string) map[string]string { + obj := doc + if o := bsonnav.DGetDoc(doc, "Object"); o != nil { + obj = o + } + out := map[string]string{} + for _, p := range bsonnav.DGetArrayElements(bsonnav.DGet(obj, "Properties")) { + pd, ok := p.(bson.D) + if !ok { + continue + } + key := keys[bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(pd, "TypePointer"))] + if key == "" { + continue + } + val := bsonnav.DGetDoc(pd, "Value") + if val == nil { + continue + } + if s, ok := bsonnav.DGet(val, "PrimitiveValue").(string); ok { + out[key] = s + } + } + return out +} + +// setPrimitiveByKey writes a primitive property of a widget object by schema key. +func setPrimitiveByKey(item bson.D, keys, kinds map[string]string, key, value string) bool { + for _, p := range bsonnav.DGetArrayElements(bsonnav.DGet(item, "Properties")) { + pd, ok := p.(bson.D) + if !ok { + continue + } + id := bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(pd, "TypePointer")) + if keys[id] != key { + continue + } + if field, _ := columnValueField(kinds[id]); field != "PrimitiveValue" { + return false + } + val := bsonnav.DGetDoc(pd, "Value") + return val != nil && bsonnav.DSet(val, "PrimitiveValue", value) + } + return false +} + +// columnPropDefaults reads the declared DefaultValue of every property of the +// object list listKey from the stored widget Type, keyed by schema key. +func columnPropDefaults(widgetDoc bson.D, listKey string) map[string]string { + out := map[string]string{} + objType := bsonnav.DGetDoc(bsonnav.DGetDoc(widgetDoc, "Type"), "ObjectType") + for _, pt := range bsonnav.DGetArrayElements(bsonnav.DGet(objType, "PropertyTypes")) { + ptDoc, ok := pt.(bson.D) + if !ok || bsonnav.DGetString(ptDoc, "PropertyKey") != listKey { + continue + } + itemType := bsonnav.DGetDoc(bsonnav.DGetDoc(ptDoc, "ValueType"), "ObjectType") + for _, cpt := range bsonnav.DGetArrayElements(bsonnav.DGet(itemType, "PropertyTypes")) { + cptDoc, ok := cpt.(bson.D) + if !ok { + continue + } + key := bsonnav.DGetString(cptDoc, "PropertyKey") + vt := bsonnav.DGetDoc(cptDoc, "ValueType") + if key == "" || vt == nil { + continue + } + if d, ok := bsonnav.DGet(vt, "DefaultValue").(string); ok { + out[key] = d + } + } + } + return out +} diff --git a/mdl/backend/pagemutator/column_hidden_reset_test.go b/mdl/backend/pagemutator/column_hidden_reset_test.go new file mode 100644 index 0000000000..8d3576e607 --- /dev/null +++ b/mdl/backend/pagemutator/column_hidden_reset_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// mendixlabs/mxcli#1519, item 3b: +// +// set (ColumnWidth: manual, Size: 220) on dg column(Name) +// set (ColumnWidth: autoFill) on dg column(Name) +// +// stored width=autoFill with size=220, and mx check answered CE0463 (11.6.6 and +// 11.15.0). Data grid 2 hides `size` unless `width` is "manual" — rules as the +// editorConfig states them, lifted per column item. + +const ( + idWidth = "aaaaaaaa-0000-0000-0000-000000000001" + idSize = "aaaaaaaa-0000-0000-0000-000000000002" + idMinWidth = "aaaaaaaa-0000-0000-0000-000000000003" + idMinLimit = "aaaaaaaa-0000-0000-0000-000000000004" +) + +// widthColumn is a column as `set (ColumnWidth: manual, Size: 220)` left it, +// with width already switched to newWidth by the setter. +func widthColumn(newWidth, minWidth string) (bson.D, map[string]string, map[string]string) { + prop := func(id, v string) bson.D { + return bson.D{ + {Key: "TypePointer", Value: bsonutil.IDToBsonBinary(id)}, + {Key: "Value", Value: bson.D{{Key: "Expression", Value: ""}, {Key: "PrimitiveValue", Value: v}}}, + } + } + col := bson.D{{Key: "Properties", Value: bson.A{ + prop(idWidth, newWidth), prop(idSize, "220"), prop(idMinWidth, minWidth), prop(idMinLimit, "250"), + }}} + keys := map[string]string{idWidth: "width", idSize: "size", idMinWidth: "minWidth", idMinLimit: "minWidthLimit"} + kinds := map[string]string{idWidth: "Enumeration", idSize: "Integer", idMinWidth: "Enumeration", idMinLimit: "Integer"} + return col, keys, kinds +} + +var columnDefaults = map[string]string{"width": "autoFill", "size": "1", "minWidth": "auto", "minWidthLimit": "100"} + +// The three Data grid 2 3.x column-width rules, as the extractor lifts them. +func widthRules() []types.WidgetVisibilityRule { + item := func(key, cond, op, val string) types.WidgetVisibilityRule { + return types.WidgetVisibilityRule{PropertyKey: key, ListPropertyKey: "columns", + HiddenWhen: &types.WidgetVisibilityCondition{PropertyKey: cond, Operator: op, Value: val, Scope: types.ConditionScopeItem}} + } + return []types.WidgetVisibilityRule{ + item("size", "width", "ne", "manual"), + item("minWidth", "width", "ne", "autoFit"), + item("minWidthLimit", "minWidth", "ne", "manual"), + } +} + +func TestResetHiddenColumnProps1519_SizeGoesBackToDefault(t *testing.T) { + col, keys, kinds := widthColumn("autoFill", "auto") + got, err := resetHiddenItemProps(col, keys, kinds, columnDefaults, nil, "columns", widthRules(), map[string]bool{"width": true}) + if err != nil { + t.Fatalf("reset: %v", err) + } + if v := fieldOf(t, col, idSize, "PrimitiveValue"); v != "1" { + t.Errorf("size = %v after switching width to autoFill, want the default 1 — 220 is CE0463", v) + } + if strings.Join(got, ",") != "minWidthLimit,size" { + t.Errorf("reset keys = %v, want [minWidthLimit size]", got) + } +} + +// A reset that hides a further property resets that one too: autoFill hides +// minWidth, and minWidth back at "auto" hides minWidthLimit. +func TestResetHiddenColumnProps1519_CascadesThroughDependentRules(t *testing.T) { + col, keys, kinds := widthColumn("autoFill", "manual") + if _, err := resetHiddenItemProps(col, keys, kinds, columnDefaults, nil, "columns", widthRules(), nil); err != nil { + t.Fatalf("reset: %v", err) + } + if v := fieldOf(t, col, idMinWidth, "PrimitiveValue"); v != "auto" { + t.Errorf("minWidth = %v, want auto (hidden unless width is autoFit)", v) + } + if v := fieldOf(t, col, idMinLimit, "PrimitiveValue"); v != "100" { + t.Errorf("minWidthLimit = %v, want 100 (hidden once minWidth is back at auto)", v) + } +} + +// CONTROL: under "manual" size is visible and keeps the author's value. +func TestResetHiddenColumnProps1519_VisibleSizeIsKept(t *testing.T) { + col, keys, kinds := widthColumn("manual", "auto") + if _, err := resetHiddenItemProps(col, keys, kinds, columnDefaults, nil, "columns", widthRules(), nil); err != nil { + t.Fatalf("reset: %v", err) + } + if v := fieldOf(t, col, idSize, "PrimitiveValue"); v != "220" { + t.Errorf("size = %v under width manual, want 220 untouched", v) + } +} + +// A hidden property the SAME statement names is not silently overwritten: +// `set (ColumnWidth: autoFill, Size: 220)` is refused, the way MDL-WIDGET10 +// refuses it on create. Its default is still accepted. +func TestResetHiddenColumnProps1519_NamedHiddenPropertyIsRefused(t *testing.T) { + col, keys, kinds := widthColumn("autoFill", "auto") + _, err := resetHiddenItemProps(col, keys, kinds, columnDefaults, nil, "columns", widthRules(), map[string]bool{"size": true}) + if err == nil || !strings.Contains(err.Error(), "CE0463") || !strings.Contains(err.Error(), `"size"`) { + t.Fatalf("want a refusal naming size and CE0463, got %v", err) + } +} + +// The defaults come from the stored Type, not from a table in mxcli. +func TestColumnPropDefaults1519_ReadFromStoredType(t *testing.T) { + pt := func(key, def string) bson.D { + return bson.D{{Key: "PropertyKey", Value: key}, {Key: "ValueType", Value: bson.D{{Key: "DefaultValue", Value: def}}}} + } + grid := bson.D{{Key: "Type", Value: bson.D{{Key: "ObjectType", Value: bson.D{{Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{{Key: "PropertyKey", Value: "columns"}, {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{{Key: "PropertyTypes", Value: bson.A{int32(2), pt("width", "autoFill"), pt("size", "1")}}}}, + }}}, + }}}}}}} + got := columnPropDefaults(grid, "columns") + if got["size"] != "1" || got["width"] != "autoFill" { + t.Errorf("defaults = %v, want size=1 width=autoFill", got) + } +} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index c546dcc190..c94154fbf1 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -347,6 +347,24 @@ func resolveAlterPageUnit(ctx *ExecContext, s *ast.AlterPageStmt, h *ContainerHi // ============================================================================ func applySetPropertyMutator(ctx *ExecContext, mutator backend.PageMutator, op *ast.SetPropertyOp, moduleName string, moduleID model.ID) error { + if err := applySetProperties(ctx, mutator, op, moduleName, moduleID); err != nil { + return err + } + if op.Target.IsColumn() { + names := make([]string, 0, len(op.Properties)) + for k := range op.Properties { + names = append(names, k) + } + sort.Strings(names) + return resetHiddenColumnProperties(ctx, mutator, op.Target, names) + } + return nil +} + +// applySetProperties writes each property of a SET, without the column +// follow-up — what check's per-property dry run exercises, since a column's +// hidden-property rules can only be judged on the statement as a whole. +func applySetProperties(ctx *ExecContext, mutator backend.PageMutator, op *ast.SetPropertyOp, moduleName string, moduleID model.ID) error { // Sort property names for deterministic application order. propNames := make([]string, 0, len(op.Properties)) for k := range op.Properties { @@ -424,6 +442,27 @@ func applySetPropertyMutator(ctx *ExecContext, mutator backend.PageMutator, op * return nil } +// resetHiddenColumnProperties puts every column property the Data grid 2 now +// hides back at its declared default, once the statement's own values are in. +// `set (ColumnWidth: autoFill)` after a manual width left `size` at 220, which +// the widget hides unless the width is manual: CE0463 (mendixlabs/mxcli#1519). +// The rules are the installed package's editorConfig — which property depends +// on which is the widget's to say, not a list kept here. +func resetHiddenColumnProperties(ctx *ExecContext, mutator backend.PageMutator, target ast.WidgetRef, setProps []string) error { + resetter, ok := mutator.(backend.ColumnHiddenPropertyResetter) + if !ok { + return nil + } + rules := resolveWidgetVisibilityRules(ctx.MprPath, pages.WidgetIDDataGrid2) + if len(rules) == 0 { + return nil + } + if _, err := resetter.ResetHiddenColumnProperties(target.Widget, columnRefOf(target), rules, setProps); err != nil { + return mdlerrors.NewBackend("set on "+target.Name(), err) + } + return nil +} + // convertASTDataSource converts an AST DataSource value to a pages.DataSource. func convertASTDataSource(value interface{}) (pages.DataSource, error) { ds, ok := value.(*ast.DataSourceV3) diff --git a/mdl/executor/cmd_alter_page_column_hidden_test.go b/mdl/executor/cmd_alter_page_column_hidden_test.go new file mode 100644 index 0000000000..b4e5c215e4 --- /dev/null +++ b/mdl/executor/cmd_alter_page_column_hidden_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// mendixlabs/mxcli#1519, item 3b: +// +// set (ColumnWidth: manual, Size: 220) on dg column(Name) +// set (ColumnWidth: autoFill) on dg column(Name) +// +// stored width=autoFill with size=220 — CE0463, measured on 11.6.6 and 11.15.0. +// The column setter changed one property and left the ones the widget hides +// because of it at whatever they held. The executor now hands the column's +// visibility rules — lifted from the installed Data grid 2 package, not written +// down here — to the mutator after a column SET. + +type columnResetRecorder struct { + backend.PageMutator + set []string + resetCalls int + rules []types.WidgetVisibilityRule + named []string +} + +func (r *columnResetRecorder) SetColumnProperty(_, _ string, prop string, _ any) error { + r.set = append(r.set, prop) + return nil +} + +func (r *columnResetRecorder) ResetHiddenColumnProperties(_, _ string, rules []types.WidgetVisibilityRule, setProps []string) ([]string, error) { + r.resetCalls++ + r.rules = rules + r.named = setProps + return nil, nil +} + +func TestAlterColumnSet1519_ResetsWhatTheWidgetHides(t *testing.T) { + mpr, err := filepath.Abs("../../testdata/expr-checker/minimal.mpr") + if err != nil { + t.Fatal(err) + } + ctx := &ExecContext{MprPath: mpr} + rec := &columnResetRecorder{} + op := &ast.SetPropertyOp{ + Target: ast.WidgetRef{Widget: "dg", ColumnAttribute: "Name"}, + Properties: map[string]any{"ColumnWidth": "autoFill"}, + } + if !op.Target.IsColumn() { + t.Fatalf("fixture target is not a column address: %+v", op.Target) + } + if err := applySetPropertyMutator(ctx, rec, op, "M", ""); err != nil { + t.Fatalf("apply: %v", err) + } + if rec.resetCalls != 1 { + t.Fatalf("hidden-property reset called %d times after a column SET, want 1", rec.resetCalls) + } + if len(rec.named) != 1 || rec.named[0] != "ColumnWidth" { + t.Errorf("named properties = %v, want [ColumnWidth]", rec.named) + } + // The rule that matters, as the installed package states it. + found := false + for _, r := range rec.rules { + if r.ListPropertyKey == "columns" && r.PropertyKey == "size" && r.HiddenWhen != nil && + r.HiddenWhen.PropertyKey == "width" && r.HiddenWhen.Operator == "ne" && r.HiddenWhen.Value == "manual" && + r.HiddenWhen.Scope == types.ConditionScopeItem { + found = true + } + } + if !found { + t.Errorf("the Data grid 2 rule `size hidden unless width is manual` was not passed; got %d rules", len(rec.rules)) + } +} diff --git a/mdl/executor/validate_alter_set.go b/mdl/executor/validate_alter_set.go index 0926c9db8a..0700d008ff 100644 --- a/mdl/executor/validate_alter_set.go +++ b/mdl/executor/validate_alter_set.go @@ -154,7 +154,7 @@ func checkSetOp(ctx *ExecContext, p pageProbe, label string, op *ast.SetProperty Target: op.Target, Properties: map[string]any{name: op.Properties[name]}, } - if err := applySetPropertyMutator(ctx, probe, one, modName, modID); err != nil { + if err := applySetProperties(ctx, probe, one, modName, modID); err != nil { if scriptDeclaresMissing(sc, err, modName) { continue } @@ -166,6 +166,18 @@ func checkSetOp(ctx *ExecContext, p pageProbe, label string, op *ast.SetProperty errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf("%s: %s", label, msg))) } } + // A column's properties are then judged TOGETHER, with the follow-up that + // resets what the widget now hides: `ColumnWidth: autoFill, Size: 220` is + // fine one property at a time and refused as a whole, since the width hides + // the size, and `ColumnWidth: manual, Size: 220` is the reverse — judged + // one at a time, Size alone would be hidden (mendixlabs/mxcli#1519). + if len(errs) == 0 && op.Target.IsColumn() { + if probe, err := p.Probe(); err == nil { + if err := applySetPropertyMutator(ctx, probe, op, modName, modID); err != nil && !scriptDeclaresMissing(sc, err, modName) { + errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf("%s: %v", label, err))) + } + } + } return errs } From 2f42df658bef3cede84eceaa77ef3fcb64bd0a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:03:49 +0000 Subject: [PATCH 14/25] fix(mdl/executor): judge an alter block's scope against the flow it writes (mendixlabs/mxcli#1517) An alter block's operations are aimed at the flow as stored, but the block writes one flow. Three scope verdicts were taken per operation, or from text that hides part of the fragment: - checkOutputUnused ran per operation, in order, against the stored candidates, so a consistent rename (producer, reader, return in one block) was refused "$Code is still used by: log ...; return $Code" by readers that later operations replace. The checks now run once every operation is applied; a return value or condition set in place marks its node rewritten, so its stored reads retire and its new ones count. - checkFragmentScope ignored what earlier operations of the block declare (unscopedRefs honoured it, but without position), so a fragment reading a variable an earlier insert declares upstream of it was refused MDL090. Each declaration now records the operation and stored target that put it in, and declaredOnPath credits it only where that site is upstream of the later operation's position; both checks use it. - the scope check read a fragment's variables from formatActivity, which renders a loop as its header only, so a replaced loop's body was never checked: a body reading an undefined variable was "Altered" and mx check gave CE0109 (11.6.6 and 11.15.0). fragmentReads now walks loop bodies, with the iterator and the body's own declarations local to it. The output check is shared with `create or modify`'s splice, so moving a retrieve and its only reader into a sub-flow is now spliced instead of refused "$Reporter is still used by" (the issue's repro A). Two tests used that false refusal as their vehicle for check/preflight prediction of a call to a flow the script creates; they now use a loop-body change, which is still refused. One commit: the A2 repro also needs the B2 fix (the replaced reader reads the $Code2 the first operation declares), and the three share the test harness. Each has its own revert control. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...lter-block-scope-judged-per-operation.json | 1 + .../write-microflows/reference/pitfalls.md | 24 ++ cmd/mxcli/exec_preflight_script_flows_test.go | 29 ++- ...1517-alter-microflow-scope-consistency.mdl | 70 +++++ mdl/executor/cmd_alter_flow.go | 163 +++++++++--- .../cmd_alter_flow_scope_pedapp_test.go | 242 ++++++++++++++++++ ..._flow_verdicts_script_flows_pedapp_test.go | 26 +- 7 files changed, 509 insertions(+), 46 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-alter-block-scope-judged-per-operation.json create mode 100644 mdl-examples/bug-tests/1517-alter-microflow-scope-consistency.mdl create mode 100644 mdl/executor/cmd_alter_flow_scope_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-alter-block-scope-judged-per-operation.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-alter-block-scope-judged-per-operation.json new file mode 100644 index 0000000000..eb23a62b45 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-alter-block-scope-judged-per-operation.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "mendixlabs/mxcli#1517 (ako#1399): three scope verdicts of one `alter microflow` block disagreed with the flow the block writes. (A2) a consistent rename — producer, log and return replaced in one block — was refused by check and exec: \"replace $Code = call microflow …: $Code is still used by: log …; return $Code\". (B2) `insert after X begin declare $Late … end; replace log … with begin …$Late… end;` was refused MDL090 \"the fragment uses $Late, which is not declared on the path\". (B3) `replace loop` whose body read an undefined $Nope passed check, exec said \"Altered microflow\", and mx check (11.6.6 and 11.15.0 alike) gave CE0109 \"Undefined variable 'Nope'\"", "cause": "applyTo ran checkOutputUnused per operation, in order, against the stored candidates, so readers a later operation replaces still counted, and an in-place return/condition edit never retired its stored reads; checkFragmentScope consulted own/inScope/removedByOps but not what earlier operations declared (unscopedRefs did, but unconditionally, without position); and it read a fragment's variables from formatActivity text, which renders a LoopedActivity as its header only, so nothing inside a loop body was ever scope-checked", "file": "mdl/executor/cmd_alter_flow.go (applyTo outputChecks, noteRewritten, declSite/declaredAt, declaredOnPath, fragmentReads, checkFragmentScope, unscopedRefs, checkOutputUnused)", "fix": "Output-in-use checks are deferred until every operation is applied, and a return value or condition set in place marks its node rewritten (its stored reads retire, the new text's reads count). Each declared variable records the operation and stored target that put it in; declaredOnPath credits it to a later operation only when that site is upstream of where the later one goes (insert after / replace t: t strictly upstream; insert before t: also t itself, unless the later op is another insert before t) — used by both the fragment scope check and the condition check. fragmentReads walks loop bodies recursively, with the iterator and the body's own outputs local to the body", "test": "mdl/executor/cmd_alter_flow_scope_pedapp_test.go TestAlterMicroflow_ScopeRenameAcrossTheBlock, TestAlterMicroflow_ScopeDeclaredByAnEarlierOperation, TestAlterMicroflow_ScopeReplaceLoopChecksTheBody — each asserts check (CheckFlowVerdicts) and exec agree, with controls (a reader the block leaves, a declaration downstream, two inserts after one node, a body using its iterator and own declare). Revert controls: A2 stub (checks run inline) -> rename refused \"$Code is still used by\"; B2 stub -> \"$Late … not declared on the path\" (and the rename test fails on $Code2); B3 stub -> exec accepts $Nope. mx check: B3 script old binary 'Altered' + CE0109 (1 error) on 11.6.6 and 11.15.0, fixed binary refuses, nothing written, 0 errors; the rename + late-declare script on the fixed binary: 0 errors on both versions", "insight": "A block of patch operations aimed at the stored flow still writes one flow, so every verdict about variables has to be taken against that final flow — per-operation checks are order-dependent and refuse consistent edits. The A2 repro also hit B2 (the reader replacement reads the new $Code2 that op 1 declared), so one bug masked the other. And a scope check that scans rendered text inherits the renderer's elisions: formatActivity prints a loop as its header, so the body was invisible to every check built on it", "refs": ["mendixlabs/mxcli#1517", "ako/mxcli#1399", "ako/mxcli#1106"]} diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 8a4807329b..a532de4095 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -329,6 +329,30 @@ body are rebuilt (new IDs, drawn by mxcli). A `while` loop is the same: `replace while $N < 3 with begin while $N < 3 begin … end while; end;`. For a loop nested in another loop, replace the outer one. +The replaced loop's body is scope-checked like any fragment: a variable it reads must +be the iterator, declared in the body, or declared before the loop. + +### 12. One Alter Block Is One Change + +Every operation of an `alter microflow` block is aimed at the flow **as stored**, but +the scope checks judge the flow the **whole block** writes: + +- a fragment may read a variable an earlier operation of the block declares, when that + declaration lands upstream of it (an `insert after`/`replace` of a node before it, or + an `insert before` the node it replaces or inserts after); +- an activity whose output is taken away is still "used" only by readers the block + leaves in place — so a rename (the producer, every reader and the `return`) goes in + **one** block: + +```mdl +mdl 1; +alter microflow MyModule.GetCode { + replace $Code = call microflow MyModule.Lookup() with begin $Code2 = call microflow MyModule.Lookup(); end; + replace log node 'App' * with begin log info node 'App' $Code2; end; + replace return $Code with begin return $Code2; end; +}; +``` + ## Implicit Variable Creation (CE0111 Duplicate Variable) These statements **implicitly create a new variable** with the name on the left side: diff --git a/cmd/mxcli/exec_preflight_script_flows_test.go b/cmd/mxcli/exec_preflight_script_flows_test.go index 2207071656..911bb33ccf 100644 --- a/cmd/mxcli/exec_preflight_script_flows_test.go +++ b/cmd/mxcli/exec_preflight_script_flows_test.go @@ -20,7 +20,7 @@ import ( // A `create or modify` whose new body calls a flow the statement before it // creates: exec's preflight said nothing, so exec wrote the new sub-flow and -// then refused the modify (MDL-V1-REBUILD, "$Reporter is still used by"), +// then refused the modify (MDL-V1-REBUILD; here a loop-body change), // leaving the script half-applied. The preflight must refuse it before // anything is written. func TestExecPreflightRefusesModifyCallingFlowTheScriptCreates(t *testing.T) { @@ -56,10 +56,13 @@ func TestExecPreflightRefusesModifyCallingFlowTheScriptCreates(t *testing.T) { return prog } if err := exec.ExecuteProgram(parse(`mdl 1; -create microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback) +create microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback) begin - retrieve $Reporter from System.User first; - change $F (Subject = $Reporter/Name); + log info node 'X' 'start'; + loop $It in $Items + begin + commit $It; + end loop; log info node 'X' 'done'; end;`)); err != nil { t.Fatalf("setup: %v", err) @@ -77,15 +80,20 @@ begin retrieve $Reporter from System.User first; change $F (Subject = $Reporter/Name); end; -create or modify microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback) +create or modify microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback) begin call microflow MyFirstModule.VerdictNewSub (F = $F); + loop $It in $Items + begin + commit $It; + log info node 'X' 'committed'; + end loop; log info node 'X' 'done'; end;`) if refusal == "" || !strings.Contains(refusal, "Nothing was written") { t.Fatalf("want the pre-flight to refuse the script, got %q\n%s", refusal, out) } - if !strings.Contains(out, "is still used by") { + if !strings.Contains(out, "changes inside its body") { t.Errorf("pre-flight output must carry exec's refusal:\n%s", out) } if strings.Contains(out, "not predicted") { @@ -95,10 +103,13 @@ end;`) // Control: a modify that only changes a log message is spliced; the // pre-flight lets it run and reports nothing unpredicted. refusal, out = run(`mdl 1; -create or modify microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback) +create or modify microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback) begin - retrieve $Reporter from System.User first; - change $F (Subject = $Reporter/Name); + log info node 'X' 'start'; + loop $It in $Items + begin + commit $It; + end loop; log info node 'X' 'finished'; end;`) if refusal != "" || strings.Contains(out, "not predicted") { diff --git a/mdl-examples/bug-tests/1517-alter-microflow-scope-consistency.mdl b/mdl-examples/bug-tests/1517-alter-microflow-scope-consistency.mdl new file mode 100644 index 0000000000..b77ad1972e --- /dev/null +++ b/mdl-examples/bug-tests/1517-alter-microflow-scope-consistency.mdl @@ -0,0 +1,70 @@ +mdl 1; +-- mendixlabs/mxcli#1517 — scope verdicts of one `alter microflow` block +-- +-- An alter block's operations are aimed at the flow as stored, but the block +-- writes ONE flow, and its scope checks must judge that flow: +-- +-- A2 a consistent rename (producer, reader, return) in one block was +-- refused "$Code is still used by: log …; return $Code" — each operation +-- was judged against readers a later one replaces; +-- B2 a fragment reading a variable an earlier operation of the block +-- declares upstream of it was refused MDL090 "not declared on the path"; +-- B3 a replaced loop's body was never scope-checked (a loop renders as its +-- header only): a body reading an undefined variable was "Altered" and +-- mx check gave CE0109 "Undefined variable". +-- +-- Run against any project with a MyFirstModule: +-- mxcli exec mdl-examples/bug-tests/1517-alter-microflow-scope-consistency.mdl -p app.mpr +-- Expected: both A2/B2 alters print "Altered microflow", then the B3 alter is +-- refused with "the fragment uses $Nope, which is not declared on the path", +-- and exec (all or nothing) rolls the whole script back. Without the B3 alter +-- the script applies and mx check reports 0 errors. Before the fix, the A2 +-- alter was refused; and the B3 alter alone was "Altered" and mx check gave +-- CE0109 (measured on 11.6.6 and 11.15.0). + +create persistent entity MyFirstModule.Bug1517Item (Name: String(100)); + +create microflow MyFirstModule.Bug1517Helper () returns String +begin + return 'x'; +end; + +create microflow MyFirstModule.Bug1517Rename () returns String +begin + $Code = call microflow MyFirstModule.Bug1517Helper(); + log info node 'T' $Code; + return $Code; +end; + +create microflow MyFirstModule.Bug1517Loop ($Items: List of MyFirstModule.Bug1517Item) +begin + log info node 'T' 'start'; + loop $It in $Items + begin + commit $It; + end loop; + log info node 'T' 'done'; +end; + +-- A2 (and B2: the reader's replacement reads the $Code2 op 1 declares). +alter microflow MyFirstModule.Bug1517Rename { + replace $Code = call microflow MyFirstModule.Bug1517Helper() with begin $Code2 = call microflow MyFirstModule.Bug1517Helper(); end; + replace log node 'T' * with begin log info node 'T' $Code2; end; + replace return $Code with begin return $Code2; end; +}; + +-- B2: a declaration an earlier operation puts upstream of the reader. +alter microflow MyFirstModule.Bug1517Loop { + insert after log node 'T' 'start' begin declare $Late Integer = 0; end; + replace log node 'T' 'done' with begin log info node 'T' toString($Late); end; +}; + +-- B3: must be refused — $Nope is declared nowhere. +alter microflow MyFirstModule.Bug1517Loop { + replace loop $It in $Items with begin + loop $It in $Items + begin + log info node 'T' $Nope; + end loop; + end; +}; diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index 29f360fdfa..adbfe888b5 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -80,6 +80,11 @@ func (a *alterFlowContext) apply(ctx *ExecContext, ops []*ast.AlterFlowOperation // applyTo applies ops to an open mutator, as apply does. func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutator, ops []*ast.AlterFlowOperation, targets []mfmutator.Candidate) error { s := a.stmt + // A stored output the block takes away is judged against the flow the + // whole block writes (mendixlabs/mxcli#1517): a reader that a later + // operation replaces or drops is no user, and a fragment of any operation + // that reads it is. So each check runs once every operation is applied. + var outputChecks []func() error for i, op := range ops { target := targets[i] fail := func(err error) error { @@ -108,9 +113,12 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato } } if op.Op == ast.AlterFlowDrop { - if err := a.checkOutputUnused(target, nil); err != nil { - return fail(err) - } + outputChecks = append(outputChecks, func() error { + if err := a.checkOutputUnused(target, nil); err != nil { + return fail(err) + } + return nil + }) if err := mut.Drop(target.ID); err != nil { return fail(err) } @@ -127,6 +135,7 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato if err != nil { return fail(err) } + a.noteRewritten(target, "if "+expr) continue } if ret, ok := returnValueEdit(op, target); ok { @@ -139,6 +148,7 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato if err != nil { return fail(err) } + a.noteRewritten(target, "return "+value) continue } frag, err := a.buildFragment(ctx, op.Body) @@ -163,9 +173,14 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato case ast.AlterFlowInsertBefore: err = mut.InsertBefore(target.ID, frag) case ast.AlterFlowReplace: - if err = a.checkOutputUnused(target, frag); err == nil { - err = mut.Replace(target.ID, frag) - } + replacement := frag + outputChecks = append(outputChecks, func() error { + if err := a.checkOutputUnused(target, replacement); err != nil { + return fail(err) + } + return nil + }) + err = mut.Replace(target.ID, frag) default: err = fmt.Errorf("unknown operation") } @@ -175,7 +190,12 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato if op.Op == ast.AlterFlowReplace { a.noteRemoved(target, frag) } - a.noteFragment(ctx, frag) + a.noteFragment(ctx, op, target, frag) + } + for _, check := range outputChecks { + if err := check(); err != nil { + return err + } } return nil } @@ -195,10 +215,14 @@ type alterFlowContext struct { // the variables their fragments declare, the ones their fragments read // (with who reads them), and the stored outputs they took away. declaredByOps map[string]bool - readByOps map[string][]string - removedByOps map[string]bool - // removedIDs are the stored activities earlier operations took out; what - // they read no longer counts as a use. + // declaredAt says where each of those fragments went, so a later + // operation counts a variable as declared only where it is on its path. + declaredAt map[string][]declSite + readByOps map[string][]string + removedByOps map[string]bool + // removedIDs are the stored activities the statement's operations take + // out or rewrite (a return given a new value, a decision a new + // condition); what they read as stored no longer counts as a use. removedIDs map[model.ID]bool // declaredVarTypes is the entity each variable holds as a full build of @@ -293,20 +317,76 @@ func (a *alterFlowContext) noteRemoved(target mfmutator.Candidate, replacement * a.removedByOps[v] = true } +// noteRewritten records that the stored node target now reads text instead of +// what it read as stored: a return value or a condition set in place. +func (a *alterFlowContext) noteRewritten(target mfmutator.Candidate, text string) { + a.removedIDs[target.ID] = true + for _, m := range variableRef.FindAllStringSubmatch(withoutStringLiterals(text), -1) { + a.readByOps[m[1]] = append(a.readByOps[m[1]], text) + } +} + +// declSite is where an operation of the statement put a fragment that +// declares a variable: the operation, and the stored node it is aimed at. +type declSite struct { + op ast.AlterFlowOpKind + target model.ID +} + // noteFragment records what an inserted or replacing fragment declares and -// reads. -func (a *alterFlowContext) noteFragment(ctx *ExecContext, frag *backend.MicroflowFragment) { +// reads, and where it goes. +func (a *alterFlowContext) noteFragment(ctx *ExecContext, op *ast.AlterFlowOperation, target mfmutator.Candidate, frag *backend.MicroflowFragment) { for _, obj := range frag.Objects { if act, ok := obj.(*microflows.ActionActivity); ok { if v := mfmutator.OutputVariable(act.Action); v != "" { a.declaredByOps[v] = true + a.declaredAt[v] = append(a.declaredAt[v], declSite{op: op.Op, target: target.ID}) } } - text := formatActivity(ctx, obj, a.entityNames, a.microflowNames) - for _, m := range variableRef.FindAllStringSubmatch(withoutStringLiterals(text), -1) { - a.readByOps[m[1]] = append(a.readByOps[m[1]], text) + } + a.fragmentReads(ctx, frag.Objects, func(v, text string) { + a.readByOps[v] = append(a.readByOps[v], text) + }) +} + +// fragmentReads calls read with each variable the objects read, and the +// activity text that reads it — inside a loop's body too, which the text of +// the loop itself (its header) does not show (mendixlabs/mxcli#1517: a +// replaced loop whose body read an undeclared variable was written, and mx +// check refused it with CE0109). A body's reads of the loop's iterator and of +// what the body itself declares are the body's own and are left out. +func (a *alterFlowContext) fragmentReads(ctx *ExecContext, objs []microflows.MicroflowObject, read func(v, text string)) { + var walk func(objs []microflows.MicroflowObject, local map[string]bool) + walk = func(objs []microflows.MicroflowObject, local map[string]bool) { + for _, obj := range objs { + text := formatActivity(ctx, obj, a.entityNames, a.microflowNames) + for _, m := range variableRef.FindAllStringSubmatch(withoutStringLiterals(text), -1) { + if !local[m[1]] { + read(m[1], text) + } + } + loop, ok := obj.(*microflows.LoopedActivity) + if !ok || loop.ObjectCollection == nil { + continue + } + inner := map[string]bool{} + for v := range local { + inner[v] = true + } + if src, ok := loop.LoopSource.(*microflows.IterableList); ok && src.VariableName != "" { + inner[src.VariableName] = true + } + for _, b := range loop.ObjectCollection.Objects { + if act, ok := b.(*microflows.ActionActivity); ok { + if v := mfmutator.OutputVariable(act.Action); v != "" { + inner[v] = true + } + } + } + walk(loop.ObjectCollection.Objects, inner) } } + walk(objs, nil) } func fragmentDeclares(frag *backend.MicroflowFragment, v string) bool { @@ -327,7 +407,7 @@ func loadAlterFlow(ctx *ExecContext, s *ast.AlterFlowStmt) (*alterFlowContext, e return nil, mdlerrors.NewBackend("build hierarchy", err) } a := &alterFlowContext{stmt: s, entityNames: getEntityNames(ctx, h), - declaredByOps: map[string]bool{}, readByOps: map[string][]string{}, removedByOps: map[string]bool{}, + declaredByOps: map[string]bool{}, declaredAt: map[string][]declSite{}, readByOps: map[string][]string{}, removedByOps: map[string]bool{}, removedIDs: map[model.ID]bool{}} // A copy: nanoflow names are added below, and the cached map is shared. a.microflowNames = map[model.ID]string{} @@ -749,16 +829,14 @@ func (a *alterFlowContext) checkFragmentScope(ctx *ExecContext, op *ast.AlterFlo } var missing []string seen := map[string]bool{} - for _, obj := range frag.Objects { - for _, m := range variableRef.FindAllStringSubmatch(withoutStringLiterals(formatActivity(ctx, obj, a.entityNames, a.microflowNames)), -1) { - v := m[1] - if seen[v] || systemVariables[v] || own[v] || (inScope[v] && !a.removedByOps[v]) { - continue - } - seen[v] = true - missing = append(missing, "$"+v) + a.fragmentReads(ctx, frag.Objects, func(v, _ string) { + if seen[v] || systemVariables[v] || own[v] || (inScope[v] && !a.removedByOps[v]) || + a.declaredOnPath(v, target.ID, op.Op) { + return } - } + seen[v] = true + missing = append(missing, "$"+v) + }) if len(missing) > 0 { sort.Strings(missing) where := "before " + op.Target @@ -773,7 +851,7 @@ func (a *alterFlowContext) checkFragmentScope(ctx *ExecContext, op *ast.AlterFlo // unscopedRefs returns the variables expr reads that do not exist where the // stored node id runs: not a parameter, not the output of an activity before // it (or one an earlier operation took away), not declared by an earlier -// operation, and not a system variable. +// operation on the path to it, and not a system variable. func (a *alterFlowContext) unscopedRefs(expr string, id model.ID) []string { inScope := map[string]bool{} for _, p := range a.mf.Parameters { @@ -793,7 +871,7 @@ func (a *alterFlowContext) unscopedRefs(expr string, id model.ID) []string { seen := map[string]bool{} for _, m := range variableRef.FindAllStringSubmatch(withoutStringLiterals(expr), -1) { v := m[1] - if seen[v] || systemVariables[v] || a.declaredByOps[v] || (inScope[v] && !a.removedByOps[v]) { + if seen[v] || systemVariables[v] || a.declaredOnPath(v, id, ast.AlterFlowReplace) || (inScope[v] && !a.removedByOps[v]) { continue } seen[v] = true @@ -803,6 +881,30 @@ func (a *alterFlowContext) unscopedRefs(expr string, id model.ID) []string { return missing } +// declaredOnPath reports whether a fragment an earlier operation of the +// statement put in declares v upstream of where an operation of kind op aimed +// at the stored node id goes (mendixlabs/mxcli#1517). Where a fragment goes +// follows from its own operation and target, both resolved against the flow +// as stored: +// +// - inserted after t, or in t's place: upstream of whatever t is upstream +// of. Two inserts after the same node do not say which runs first, so +// neither counts for the other; +// - inserted before t: upstream of t too, so upstream of a replacement of +// t and of an insert after it, but not of another insert before it. +func (a *alterFlowContext) declaredOnPath(v string, id model.ID, op ast.AlterFlowOpKind) bool { + for _, site := range a.declaredAt[v] { + including := false + if site.op == ast.AlterFlowInsertBefore { + including = op != ast.AlterFlowInsertBefore + } + if a.upstreamOf(id, including)[site.target] { + return true + } + } + return false +} + // upstreamOf returns every object from which id can be reached along the // stored flows — the activities whose outputs exist when the flow gets there. // id itself is included only when including says so (an insert after it runs @@ -849,13 +951,16 @@ func (a *alterFlowContext) upstreamOf(id model.ID, including bool) map[model.ID] // checkOutputUnused refuses to take away an activity whose output variable // another activity still reads — unless the replacement declares it again. +// It runs once every operation of the statement is applied, so the readers it +// counts are those of the flow the statement writes: a stored activity that an +// operation takes out or rewrites is none, a fragment that reads it is one. func (a *alterFlowContext) checkOutputUnused(target mfmutator.Candidate, replacement *backend.MicroflowFragment) error { v := target.OutputVariable if v == "" || fragmentDeclares(replacement, v) { return nil } if readers := a.readByOps[v]; len(readers) > 0 { - return fmt.Errorf("$%s is read by what an earlier operation of this alter adds: %s", v, strings.Join(readers, "; ")) + return fmt.Errorf("$%s is read by what an operation of this alter adds: %s", v, strings.Join(readers, "; ")) } ref := regexp.MustCompile(`\$` + regexp.QuoteMeta(v) + `\b`) var users []string diff --git a/mdl/executor/cmd_alter_flow_scope_pedapp_test.go b/mdl/executor/cmd_alter_flow_scope_pedapp_test.go new file mode 100644 index 0000000000..2d5bcd8b23 --- /dev/null +++ b/mdl/executor/cmd_alter_flow_scope_pedapp_test.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1517: an alter block is one change, so its scope checks +// have to judge the flow the whole block writes — not each operation against +// the flow as stored, nor what the formatter happens to print of a fragment. + +const scopeHelperFlow = `mdl 1; +create microflow MyFirstModule.ScopeHelper () returns String +begin + return 'x'; +end;` + +const scopeRenameFlow = `mdl 1; +create microflow MyFirstModule.ScopeRename () returns String +begin + $Code = call microflow MyFirstModule.ScopeHelper(); + log info node 'T' $Code; + return $Code; +end;` + +const scopeLoopFlow = `mdl 1; +create microflow MyFirstModule.ScopeLoop ($Items: List of FeedbackModule.Feedback) +begin + log info node 'T' 'start'; + loop $It in $Items + begin + commit $It; + end loop; + log info node 'T' 'done'; +end;` + +func openScopeFixture(t *testing.T) *Executor { + t.Helper() + exec, out, _ := openPedAppCopy(t) + for _, src := range []string{scopeHelperFlow, scopeRenameFlow, scopeLoopFlow} { + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + } + return exec +} + +// scopeAgree runs check's flow verdict, then exec, and asserts both refuse +// (want != "", found in both) or both accept. +func scopeAgree(t *testing.T, exec *Executor, src, want string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + var says []string + for _, v := range exec.CheckFlowVerdicts(prog) { + says = append(says, v.Message) + } + checkSays := strings.Join(says, "\n") + execErr := exec.ExecuteProgram(prog) + if want == "" { + if checkSays != "" || execErr != nil { + t.Errorf("check and exec must both accept\ncheck: %s\nexec: %v", checkSays, execErr) + } + return + } + if !strings.Contains(checkSays, want) { + t.Errorf("check must predict the refusal %q, reported: %s", want, checkSays) + } + if execErr == nil || !strings.Contains(execErr.Error(), want) { + t.Errorf("exec must refuse with %q, got: %v", want, execErr) + } +} + +// A2: a consistent rename of a variable — its producer, every reader and the +// return, in one block — was refused "$Code is still used by: log …; return +// $Code", because each operation was judged against readers that later +// operations of the same block replace. +func TestAlterMicroflow_ScopeRenameAcrossTheBlock(t *testing.T) { + const rename = `mdl 1; +alter microflow MyFirstModule.ScopeRename { + replace $Code = call microflow MyFirstModule.ScopeHelper() with begin $Code2 = call microflow MyFirstModule.ScopeHelper(); end; + replace log node 'T' * with begin log info node 'T' $Code2; end; + replace return $Code with begin return $Code2; end; +};` + t.Run("the whole rename is accepted by check and exec", func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, rename, "") + got := describeFlow(t, exec, "ScopeRename") + if strings.Contains(got, "$Code;") || strings.Contains(got, "$Code\n") || !strings.Contains(got, "return $Code2;") { + t.Errorf("the rename was not written:\n%s", got) + } + }) + // Controls: a reader that the block leaves in place still blocks the + // rename, whichever order the operations come in. + for name, src := range map[string]string{ + "the return still reads $Code": `mdl 1; +alter microflow MyFirstModule.ScopeRename { + replace $Code = call microflow MyFirstModule.ScopeHelper() with begin $Code2 = call microflow MyFirstModule.ScopeHelper(); end; + replace log node 'T' * with begin log info node 'T' $Code2; end; +};`, + "the log still reads $Code, the rename comes last": `mdl 1; +alter microflow MyFirstModule.ScopeRename { + replace return $Code with begin return 'y'; end; + replace $Code = call microflow MyFirstModule.ScopeHelper() with begin $Code2 = call microflow MyFirstModule.ScopeHelper(); end; +};`, + "a fragment of the block reads $Code": `mdl 1; +alter microflow MyFirstModule.ScopeRename { + insert before log node 'T' * begin log info node 'T' $Code; end; + replace $Code = call microflow MyFirstModule.ScopeHelper() with begin $Code2 = call microflow MyFirstModule.ScopeHelper(); end; + replace log node 'T' * with begin log info node 'T' $Code2; end; + replace return $Code with begin return $Code2; end; +};`, + } { + t.Run("control: "+name, func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, src, "$Code") + }) + } +} + +// A2 through `create or modify`, which splices with the same operations: a +// retrieve and its only reader moved into a sub-flow were refused "$Reporter +// is still used by: change $F …", by the reader the same patch drops. +func TestCreateOrModify_ScopeProducerAndReaderMovedOut(t *testing.T) { + exec := openScopeFixture(t) + for _, src := range []string{`mdl 1; +create microflow MyFirstModule.ScopeMoved ($F: FeedbackModule.Feedback) +begin + retrieve $Reporter from System.User first; + change $F (Subject = $Reporter/Name); + log info node 'X' 'done'; +end;`, `mdl 1; +create microflow MyFirstModule.ScopeMovedSub ($F: FeedbackModule.Feedback) +begin + retrieve $Reporter from System.User first; + change $F (Subject = $Reporter/Name); +end;`} { + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("setup: %v", err) + } + } + scopeAgree(t, exec, `mdl 1; +create or modify microflow MyFirstModule.ScopeMoved ($F: FeedbackModule.Feedback) +begin + call microflow MyFirstModule.ScopeMovedSub (F = $F); + log info node 'X' 'done'; +end;`, "") + got := describeFlow(t, exec, "ScopeMoved") + if strings.Contains(got, "$Reporter") || !strings.Contains(got, "call microflow MyFirstModule.ScopeMovedSub") { + t.Errorf("the change was not written:\n%s", got) + } +} + +// B2: a declaration made by an earlier operation of the block is on the path +// of a later one that reads it, when it is upstream of where that one goes. +func TestAlterMicroflow_ScopeDeclaredByAnEarlierOperation(t *testing.T) { + t.Run("a later fragment reads a variable an earlier insert declares", func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + insert after log node 'T' 'start' begin declare $Late Integer = 0; end; + replace log node 'T' 'done' with begin log info node 'T' toString($Late); end; +};`, "") + if got := describeFlow(t, exec, "ScopeLoop"); !strings.Contains(got, "toString($Late)") { + t.Errorf("the replacement was not written:\n%s", got) + } + }) + t.Run("an insert before the reader's target declares it", func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + insert before log node 'T' 'done' begin declare $Late Integer = 0; end; + replace log node 'T' 'done' with begin log info node 'T' toString($Late); end; +};`, "") + got := describeFlow(t, exec, "ScopeLoop") + if d, r := strings.Index(got, "declare $Late"), strings.Index(got, "toString($Late)"); d < 0 || r < d { + t.Errorf("the declaration is not written before its reader:\n%s", got) + } + }) + // Controls: a declaration the block makes downstream of the reader is + // not on its path. + for name, src := range map[string]string{ + "declared after the reader": `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + insert after log node 'T' 'done' begin declare $Late Integer = 0; end; + insert after log node 'T' 'start' begin log info node 'T' toString($Late); end; +};`, + "two inserts after the same node": `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + insert after log node 'T' 'start' begin declare $Late Integer = 0; end; + insert after log node 'T' 'start' begin log info node 'T' toString($Late); end; +};`, + "declared after the replaced reader": `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + insert after log node 'T' 'done' begin declare $Late Integer = 0; end; + replace log node 'T' 'done' with begin log info node 'T' toString($Late); end; +};`, + } { + t.Run("control: "+name, func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, src, "not declared on the path") + }) + } +} + +// B3: a replaced loop's body was never scope-checked — the formatter renders +// a loop as its header only — so a body reading an undeclared variable was +// "Altered" and failed mx check with CE0109 "Undefined variable". +func TestAlterMicroflow_ScopeReplaceLoopChecksTheBody(t *testing.T) { + t.Run("a body reading an undeclared variable is refused", func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + replace loop $It in $Items with begin + loop $It in $Items + begin + log info node 'T' $Nope; + end loop; + end; +};`, "$Nope") + }) + t.Run("control: the iterator and what the body declares are in scope", func(t *testing.T) { + exec := openScopeFixture(t) + scopeAgree(t, exec, `mdl 1; +alter microflow MyFirstModule.ScopeLoop { + replace loop $It in $Items with begin + loop $It in $Items + begin + declare $N Integer = 1; + log info node 'T' toString($N); + commit $It; + end loop; + end; +};`, "") + }) +} diff --git a/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go b/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go index 329f0823e3..f563b1298d 100644 --- a/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go +++ b/mdl/executor/validate_flow_verdicts_script_flows_pedapp_test.go @@ -17,14 +17,19 @@ import ( // refused the flow, leaving the script half-applied. The verdict build now // resolves calls to flows the script itself declares. -// verdictActStored is the flow as stored: a retrieve whose output a later -// change reads. Moving both into a sub-flow and calling it replaces the -// retrieve while the change still reads $Reporter, which the splice refuses. +// verdictActStored is the flow as stored: a log, a loop and a log. The new +// body calls a sub-flow and also adds an activity to the loop's body, which +// the splice refuses. (It used to move a retrieve and its reader into the +// sub-flow, refused "$Reporter is still used by" — a false refusal, since +// the reader goes too, which mendixlabs/mxcli#1517 fixed.) const verdictActStored = `mdl 1; -create microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback) +create microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback) begin - retrieve $Reporter from System.User first; - change $F (Subject = $Reporter/Name); + log info node 'X' 'start'; + loop $It in $Items + begin + commit $It; + end loop; log info node 'X' 'done'; end;` @@ -35,9 +40,14 @@ begin end;` func verdictActCallingSub(sub string) string { - return `create or modify microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback) + return `create or modify microflow MyFirstModule.VerdictAct ($F: FeedbackModule.Feedback, $Items: List of FeedbackModule.Feedback) begin call microflow MyFirstModule.` + sub + ` (F = $F); + loop $It in $Items + begin + commit $It; + log info node 'X' 'committed'; + end loop; log info node 'X' 'done'; end;` } @@ -61,7 +71,7 @@ func verdictRefusals(vs []linter.Violation) string { return strings.Join(says, "\n") } -const stillUsed = "is still used by" +const stillUsed = "changes inside its body" func TestCheckFlowVerdicts_CallToFlowTheScriptCreates(t *testing.T) { exec, out, _ := openPedAppCopy(t) From e411b54f9dc4379ff48e3ec98c58c8471844f828 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:03:54 +0000 Subject: [PATCH 15/25] fix(mdl/executor): name the deliberate rebuild in the loop-body refusal (mendixlabs/mxcli#1517) The MDL-V1-REBUILD refusal of a `create or modify` that changes a loop body named only the replace-loop alter, while every other splice refusal also names the deliberate rebuild: drop the flow and create it in the same script. Both now share that advice (deliberateRebuildAdvice), so check, which quotes exec's refusal, says it too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .../cmd_flow_loop_advice_pedapp_test.go | 5 +++++ mdl/executor/flow_verdict.go | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/mdl/executor/cmd_flow_loop_advice_pedapp_test.go b/mdl/executor/cmd_flow_loop_advice_pedapp_test.go index 6236e8b5df..536d1e6043 100644 --- a/mdl/executor/cmd_flow_loop_advice_pedapp_test.go +++ b/mdl/executor/cmd_flow_loop_advice_pedapp_test.go @@ -99,6 +99,11 @@ func TestFlowLoopRefusals_PointAtReplaceLoop(t *testing.T) { if !strings.Contains(msg, "alter microflow MyFirstModule.LoopAdvice {") { t.Errorf("the refusal does not spell out the alter to run: %s", msg) } + // mendixlabs/mxcli#1517: the deliberate rebuild is named too, as + // every other splice refusal names it. + if !strings.Contains(msg, "drop the microflow and create it in the same script") { + t.Errorf("the refusal does not name the drop-and-recreate route: %s", msg) + } // The generic advice is what sent the reader into alter's own // in-loop refusal. if strings.Contains(msg, "Change activities with") { diff --git a/mdl/executor/flow_verdict.go b/mdl/executor/flow_verdict.go index 8976b5f5ed..dc77a5925a 100644 --- a/mdl/executor/flow_verdict.go +++ b/mdl/executor/flow_verdict.go @@ -64,20 +64,26 @@ func flowRefusal(d *flowDecl, why error) error { // alter cannot splice inside a loop either, so "change activities // with alter" would only lead to alter's own refusal: name the one // alter that makes the change. + // The deliberate rebuild is a route here too (mendixlabs/mxcli#1517). return mdlerrors.NewValidation(fmt.Sprintf( "create or modify %s %s: this change cannot be spliced into the stored flow: %v. "+ "Nothing was written: rebuilding the whole flow instead would reset what Studio Pro drew "+ - "(curves, merges, element IDs). %s", - d.kind(), d.name, why, replaceLoopAdvice(d.kind(), d.name.String(), ns.loopHandle))) + "(curves, merges, element IDs). %s. Or, %s", + d.kind(), d.name, why, strings.TrimSuffix(replaceLoopAdvice(d.kind(), d.name.String(), ns.loopHandle), "."), + deliberateRebuildAdvice(d.kind()))) } return mdlerrors.NewValidation(fmt.Sprintf( "create or modify %s %s: this change cannot be spliced into the stored flow: %v. "+ "Nothing was written: rebuilding the whole flow instead would reset what Studio Pro drew "+ - "(curves, merges, element IDs). Change activities with `alter %s %s { … }`; "+ - "to rebuild the flow deliberately, drop the %s and create it in the same script "+ - "(the drop's module-role grants carry to a create in the same script or session, not to one in a later run)", - d.kind(), d.name, why, d.kind(), d.name, d.kind())) + "(curves, merges, element IDs). Change activities with `alter %s %s { … }`; %s", + d.kind(), d.name, why, d.kind(), d.name, deliberateRebuildAdvice(d.kind()))) +} +// deliberateRebuildAdvice is the route every splice refusal names for a +// rebuild the author means: drop the flow and create it in the same script. +func deliberateRebuildAdvice(kind string) string { + return fmt.Sprintf("to rebuild the flow deliberately, drop the %s and create it in the same script "+ + "(the drop's module-role grants carry to a create in the same script or session, not to one in a later run)", kind) } // replaceLoopAdvice is what `create or modify` and `alter` both say about a From 6ab9bc178b1aaa17eb17fd9e6795ffcd8946a037 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:49:39 +0000 Subject: [PATCH 16/25] fix(exprcheck): check built-in argument kinds, without a project too (mendixlabs/mxcli#1515) `formatDecimal($d, 2)` passes a number where the String pattern goes. mxbuild refuses it with CE0117 on 11.6.6 and 11.15.0, but `mxcli check` printed "Check passed!" with and without -p. Cause: checkCallExpr compared argument counts only, although its comment and the E005 func-arg-type registry entry promised kinds; and it ran only in the catalog tier (TypeCheckProgram, `check -p --references`), so a plain check missed even a wrong count such as `formatDecimal($d, '#', 'en', 4)`. Fix: - checkCallExpr now reports E005 when an argument's inferred kind cannot stand in the parameter: Boolean / String / number / DateTime are separate families; Integer and Long are interchangeable and widen to Decimal; Decimal into an Integer/Long parameter is refused. Unknown, empty, enumeration, object and Any are always tolerated, date tokens are typed DateTime, and rows marked unverified are skipped. - BuiltinCallHints runs E005/E006 in the no-project validator (checkExprFunctions, microflows and nanoflows) with the kinds of literals, parameters and declares. The catalog tier only runs on a script this tier passed, so the two never report the same call. Not exec-enforced: --no-check still writes. - Every mismatch class and the signature rows below were measured with mx check on 11.6.6 and 11.15.0 (they agreed on all ~130 probes) and pinned in builtin_arg_types_test.go. Turning the check on surfaced arity rows that were already false E006s on the -p tier: find/findLast take a start index, max/min are variadic and accept DateTimes, dateTime/dateTimeUTC take 1-6 parts, formatDateTime(UTC)'s format is optional. formatDecimal's format is required (min 2, was 1). parseDecimal's second argument may be a Decimal default. - The one new diagnostic over mdl-examples was a true positive: use-cases/02-agentic-search.mdl passed the quoted string '[%CurrentDateTime%]' to addDays (CE0117); unquoted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...0-16-builtin-argument-types-unchecked.json | 1 + .../skills/mendix/write-microflows/SKILL.md | 6 +- cmd/mxcli/testrunner/expect.go | 2 +- .../1515-builtin-argument-types.fail.mdl | 41 +++ .../bug-tests/1515-builtin-argument-types.mdl | 44 ++++ mdl-examples/use-cases/02-agentic-search.mdl | 2 +- .../validate_builtin_arg_types_test.go | 102 ++++++++ mdl/executor/validate_microflow.go | 10 + mdl/exprcheck/builtin_arg_types_test.go | 204 +++++++++++++++ mdl/exprcheck/func_checker.go | 245 +++++++++++++++--- mdl/exprcheck/func_checker_test.go | 2 +- mdl/exprcheck/infer.go | 26 ++ 12 files changed, 637 insertions(+), 48 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-16-builtin-argument-types-unchecked.json create mode 100644 mdl-examples/bug-tests/1515-builtin-argument-types.fail.mdl create mode 100644 mdl-examples/bug-tests/1515-builtin-argument-types.mdl create mode 100644 mdl/executor/validate_builtin_arg_types_test.go create mode 100644 mdl/exprcheck/builtin_arg_types_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-16-builtin-argument-types-unchecked.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-16-builtin-argument-types-unchecked.json new file mode 100644 index 0000000000..7b99c65128 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-16-builtin-argument-types-unchecked.json @@ -0,0 +1 @@ +{"area": "mdl/exprcheck", "date": "2026-10-16", "symptom": "mendixlabs/mxcli#1515: `return formatDecimal($d, 2);` (a number where the String pattern goes) — `mxcli check` prints \"Check passed!\" with and without -p; mx check on 11.6.6 and 11.15.0 reports CE0117 \"Error(s) in expression\". A bare check also missed `formatDecimal($d, '#', 'en', 4)`", "cause": "checkCallExpr compared argument COUNTS only, though its comment and the E005 func-arg-type registry entry promised kinds; and it ran only in the catalog tier (TypeCheckProgram, `check -p --references`), so the no-project tier — where literals, parameters and declares already type the arguments — checked nothing", "file": "`mdl/exprcheck/func_checker.go` (checkCallExpr, argAccepted, funcTable), `mdl/exprcheck/infer.go` (BuiltinCallHints), `mdl/executor/validate_microflow.go` (checkExprFunctions), tests `mdl/exprcheck/builtin_arg_types_test.go`, `mdl/executor/validate_builtin_arg_types_test.go`, `mdl-examples/bug-tests/1515-builtin-argument-types.mdl`", "insight": "Measure the mismatch CLASSES, one `return ;` microflow each, with `mx check -j out.json`: the JSON names the document, so ~70 cases fit in one project copy (the text output only says 'at End event'). 11.6.6 and 11.15.0 agreed on every row. Integer and Long are interchangeable and both widen to Decimal, but Decimal into an Integer/Long parameter is CE0117 (addDays($dt, 1.5)), so 'numeric widening only' is the rule, not 'any number'. Turning the type check on surfaced four funcTable arities that were already false E006s on the -p tier (find/findLast take a start index, max/min are variadic and take DateTimes, dateTime takes 1-6 parts, formatDateTime's format is optional) and one that was too lenient (formatDecimal's format is required) — measure the table's arities before widening where it runs. inferKind types every [%Token%] as String; [%CurrentDateTime%] and the BeginOf/EndOf tokens are DateTimes, so the argument check types them itself. The one new diagnostic over mdl-examples was a true positive: addDays('[%CurrentDateTime%]', 7) passes a quoted string, CE0117 on both versions.", "ce": ["CE0117"], "rules": ["E005", "E006"]} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index e87d3e335d..af58d4d850 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -431,9 +431,9 @@ toString($value) -- Convert to string > `round(random() * 8)`. Because `random()` is a Decimal, assigning it (or `div`, > `secondsBetween()`, and the other duration `*Between` functions) directly to an > `integer` variable fails the build with CE0117 — wrap it in `round()`/`floor()`/`ceil()`. -> `mxcli check` now flags an unknown expression function like `randomInt` as -> **MDL044** (with a "did you mean random()?" hint), and a Decimal assigned to an -> integer target as **MDL041** — before the build does. +> `mxcli check` flags an unknown function like `randomInt` as **MDL044**, a Decimal +> assigned to an integer target as **MDL041**, and a built-in given the wrong number +> or kind of arguments (`formatDecimal($d, 2)`: the pattern is a String) as **E006**/**E005**. > > **MDL044 also blocks `mxcli exec`**, not just `check`: a call to a name Mendix > has no built-in for is CE0117 at build time, so exec refuses to write the diff --git a/cmd/mxcli/testrunner/expect.go b/cmd/mxcli/testrunner/expect.go index 8ae3c94e31..abf843075e 100644 --- a/cmd/mxcli/testrunner/expect.go +++ b/cmd/mxcli/testrunner/expect.go @@ -748,7 +748,7 @@ func (p *expectParser) parseCall() (expectNode, error) { if minArgs == 0 { minArgs = len(sig.Args) } - if len(args) < minArgs || len(args) > len(sig.Args) { + if len(args) < minArgs || (!sig.Variadic && len(args) > len(sig.Args)) { return nil, p.errorAt(nameTok, "%s() takes %s, got %d", name, arityText(minArgs, len(sig.Args)), len(args)) } diff --git a/mdl-examples/bug-tests/1515-builtin-argument-types.fail.mdl b/mdl-examples/bug-tests/1515-builtin-argument-types.fail.mdl new file mode 100644 index 0000000000..5560d70a52 --- /dev/null +++ b/mdl-examples/bug-tests/1515-builtin-argument-types.fail.mdl @@ -0,0 +1,41 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1515 - check passes built-in calls whose +-- argument types are wrong +-- ============================================================================ +-- +-- Symptom: +-- `formatDecimal($d, 2)` hands a number to the String pattern. `mxcli check` +-- printed "Check passed!" with and without -p; mxbuild refuses it with +-- CE0117 "Error(s) in expression" (11.6.6 and 11.15.0). The built-in +-- signature check compared argument COUNTS only, and only on the +-- `-p --references` tier, so a plain check missed even +-- `formatDecimal($d, '#', 'en', 4)`. +-- +-- After fix: +-- `mxcli check` (no project needed) reports E005 on Tmp1515.Bad_Pattern and +-- Tmp1515.Bad_Days, and E006 on Tmp1515.Bad_Arity. Each is CE0117 on mx +-- check 11.6.6 and 11.15.0. +-- +-- Usage: +-- mxcli check mdl-examples/bug-tests/1515-builtin-argument-types.fail.mdl +-- Expect exactly three errors: E005 x2, E006 x1. The controls, which must +-- stay clean, are in 1515-builtin-argument-types.mdl. +-- ============================================================================ + +create module Tmp1515; + +create microflow Tmp1515.Bad_Pattern ($d: Decimal) returns String +begin + return formatDecimal($d, 2); +end; + +create microflow Tmp1515.Bad_Days ($dt: DateTime) returns DateTime +begin + return addDays($dt, 1.5); +end; + +create microflow Tmp1515.Bad_Arity ($d: Decimal) returns String +begin + return formatDecimal($d, '#', 'en', 4); +end; diff --git a/mdl-examples/bug-tests/1515-builtin-argument-types.mdl b/mdl-examples/bug-tests/1515-builtin-argument-types.mdl new file mode 100644 index 0000000000..6000314da9 --- /dev/null +++ b/mdl-examples/bug-tests/1515-builtin-argument-types.mdl @@ -0,0 +1,44 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1515 - controls for the built-in argument check +-- ============================================================================ +-- +-- The negative cases are in 1515-builtin-argument-types.fail.mdl. Each call +-- below builds at 0 errors on mx check 11.6.6 and 11.15.0, so `mxcli check` +-- must pass this file: the argument check may not refuse any of them. +-- +-- Usage: +-- mxcli check mdl-examples/bug-tests/1515-builtin-argument-types.mdl +-- Expect: Check passed. +-- ============================================================================ + +create module Tmp1515; + +-- Controls: Integer/Long widen to Decimal, Long passes for Integer, max/min +-- take more than two operands and DateTimes, find takes a start index, +-- dateTime takes from 1 to 6 parts, and parseDecimal's second argument is a +-- format or a default. +create microflow Tmp1515.Ok_Pattern ($i: Integer) returns String +begin + return formatDecimal($i, '#,##0.00'); +end; + +create microflow Tmp1515.Ok_Days ($dt: DateTime, $l: Long) returns DateTime +begin + return addDays($dt, $l); +end; + +create microflow Tmp1515.Ok_Max ($dt: DateTime) returns DateTime +begin + return max($dt, [%CurrentDateTime%], dateTime(2020)); +end; + +create microflow Tmp1515.Ok_Find ($s: String) returns Integer +begin + return find($s, 'a', 2); +end; + +create microflow Tmp1515.Ok_ParseDecimal ($s: String) returns Decimal +begin + return parseDecimal($s, 2); +end; diff --git a/mdl-examples/use-cases/02-agentic-search.mdl b/mdl-examples/use-cases/02-agentic-search.mdl index fdd680a3f3..81be90e92b 100644 --- a/mdl-examples/use-cases/02-agentic-search.mdl +++ b/mdl-examples/use-cases/02-agentic-search.mdl @@ -240,7 +240,7 @@ begin TaskTitle = $TaskTitle, status = 'Open', Priority = $TaskPriority, - DueDate = addDays('[%CurrentDateTime%]', 7) + DueDate = addDays([%CurrentDateTime%], 7) ); change $NewTask (Task_TaskProject = $TaskProject); commit $NewTask; diff --git a/mdl/executor/validate_builtin_arg_types_test.go b/mdl/executor/validate_builtin_arg_types_test.go new file mode 100644 index 0000000000..5e8cac56eb --- /dev/null +++ b/mdl/executor/validate_builtin_arg_types_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// TestValidateMicroflow_BuiltinArgumentTypes guards mendixlabs/mxcli#1515 on +// the tier that runs without a project. `formatDecimal($d, 2)` hands a number +// to the String pattern and mxbuild refuses it (CE0117 on 11.6.6 and 11.15.0), +// yet `mxcli check` printed "Check passed!": the built-in signature check +// compared only argument COUNTS, and only on the `-p --references` tier. The +// kinds it needs here — literals, parameters, declared variables — are all in +// the script. +func TestValidateMicroflow_BuiltinArgumentTypes(t *testing.T) { + cases := []struct { + name string + body string + wantRule string // "" = no E005/E006 + wantMsg string + }{ + { + name: "issue repro: number as the formatDecimal pattern", + body: "declare $r String = formatDecimal($d, 2);\n return $r;", + wantRule: "E005", + wantMsg: "formatDecimal() argument 2", + }, + { + name: "control: String pattern", + body: "declare $r String = formatDecimal($d, '#,##0.00');\n return $r;", + wantRule: "", + }, + { + name: "arity without a project", + body: "declare $r String = formatDecimal($d, '#', 'en', 4);\n return $r;", + wantRule: "E006", + wantMsg: "formatDecimal()", + }, + { + name: "a declared variable's kind is used", + body: "declare $n Integer = 3;\n declare $r String = toUpperCase($n);\n return $r;", + wantRule: "E005", + wantMsg: "toUpperCase() argument 1", + }, + { + name: "inside a create member value", + body: "$o = create Tmp.Item (Lbl = formatDecimal($d, 2));\n return 'x';", + wantRule: "E005", + }, + { + name: "unknown variable stays quiet", + body: "declare $r String = formatDecimal($nope, '#');\n return $r;", + wantRule: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mf := buildMF(t, "create microflow Tmp.F1 ($d: Decimal) returns String\nbegin\n "+c.body+"\nend;") + var got []string + for _, v := range ValidateMicroflow(mf) { + if v.RuleID != "E005" && v.RuleID != "E006" { + continue + } + got = append(got, v.RuleID+": "+v.Message) + if v.Severity != linter.SeverityError { + t.Errorf("%s must be an error, got %v", v.RuleID, v.Severity) + } + } + if c.wantRule == "" { + if len(got) != 0 { + t.Fatalf("want no built-in signature violation, got %v", got) + } + return + } + if len(got) != 1 || !strings.HasPrefix(got[0], c.wantRule+": ") || !strings.Contains(got[0], c.wantMsg) { + t.Fatalf("want one %s mentioning %q, got %v", c.wantRule, c.wantMsg, got) + } + }) + } +} + +// TestValidateNanoflow_BuiltinArgumentTypes: the nanoflow walk shares the +// expression sites, and its kinds come from seedPrimitiveKinds. +func TestValidateNanoflow_BuiltinArgumentTypes(t *testing.T) { + nf := buildNF(t, `create nanoflow Tmp.NF ($d: Decimal) returns String +begin + return formatDecimal($d, 2); +end;`) + found := false + for _, v := range ValidateNanoflow(nf) { + if v.RuleID == "E005" { + found = true + } + } + if !found { + t.Fatal("want E005 for formatDecimal($d, 2) in a nanoflow") + } +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 7e3852f6e1..fef937f973 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -592,6 +592,16 @@ func (v *microflowValidator) checkExprFunctions(label string, expr ast.Expressio "the build fails CE0117 \"Error(s) in expression\"", label, u.Name), suggestion) } + // E006/E005: a built-in called with the wrong number of arguments, or with + // an argument of the wrong kind. Both ran only on the `-p --references` + // tier, so `formatDecimal($d, 2)` passed a plain check and failed the build + // with CE0117 (mendixlabs/mxcli#1515). The kinds a script states — literals, + // parameters, declared variables — need no project. The codes are + // exprcheck's own, as on the catalog tier; that tier only runs on a script + // this one passed, so the two never report the same call. + for _, h := range exprcheck.BuiltinCallHints(src, v.varKinds) { + v.addViolation(h.Code, linter.SeverityError, label+": "+h.Problem, h.Fix) + } } // checkDivisionSlash flags `/` used as an arithmetic division operator, which diff --git a/mdl/exprcheck/builtin_arg_types_test.go b/mdl/exprcheck/builtin_arg_types_test.go new file mode 100644 index 0000000000..123a111f16 --- /dev/null +++ b/mdl/exprcheck/builtin_arg_types_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +package exprcheck + +import "testing" + +// builtinProbeVars are the parameters every probe microflow declared when the +// cases below were measured: ($d: Decimal, $i: Integer, $l: Long, $s: String, +// $b: Boolean, $dt: DateTime). +var builtinProbeVars = map[string]TypeKind{ + "d": KindDecimal, "i": KindInteger, "l": KindLong, + "s": KindString, "b": KindBoolean, "dt": KindDateTime, +} + +// TestBuiltinCallHints_MeasuredArgumentTypes pins E005 (a built-in given an +// argument of the wrong kind) and E006 (wrong argument count) to what mxbuild +// does. Every row was built as `return ;` in its own microflow with the +// parameters above and run through `mx check` on 11.6.6 AND 11.15.0, which +// agreed on every row: wantCode "" built at 0 errors, every other row is +// CE0117 "Error(s) in expression" (mendixlabs/mxcli#1515). +func TestBuiltinCallHints_MeasuredArgumentTypes(t *testing.T) { + cases := []struct { + expr string + wantCode string + }{ + // The reported case: a number where the String pattern goes. + {"formatDecimal($d, 2)", "E005"}, + {"formatDecimal($d, '#,##0.00')", ""}, + {"formatDecimal($i, '#')", ""}, // Integer widens to Decimal + {"formatDecimal($l, '#')", ""}, // so does Long + {"formatDecimal($d, $s)", ""}, + {"formatDecimal($d, '#', 'en-US')", ""}, + {"formatDecimal($d, '#', 1)", "E005"}, + {"formatDecimal($d, '#', $i)", "E005"}, + // The pattern is not optional, whatever the documentation suggests. + {"formatDecimal($d)", "E006"}, + {"formatDecimal($d, '#', 'en', 4)", "E006"}, + + // number → String + {"length(5)", "E005"}, + {"length($i)", "E005"}, + {"toUpperCase($d)", "E005"}, + {"contains($s, 1)", "E005"}, + {"isMatch($s, 5)", "E005"}, + {"replaceAll($s, 'a', 1)", "E005"}, + {"replaceAll($s, $i, 'a')", "E005"}, + {"urlEncode(1)", "E005"}, + {"parseInteger(5)", "E005"}, + {"parseDateTime(5, 'yyyy')", "E005"}, + {"formatDate($dt, 5)", "E005"}, + {"trim(if $b then 1 else 2)", "E005"}, + // String + number concatenates: still a String. + {"length($s + $i)", ""}, + {"startsWith($s, 'a' + 1)", ""}, + {"trim(if $b then 'a' else 'b')", ""}, + + // Boolean / DateTime → String + {"length(true)", "E005"}, + {"trim($b)", "E005"}, + {"length($dt)", "E005"}, + {"urlDecode($dt)", "E005"}, + + // String / Boolean / DateTime → number + {"substring('abc', '1')", "E005"}, + {"addDays($dt, '1')", "E005"}, + {"round('1.5')", "E005"}, + {"dateTime('2020', 1, 1)", "E005"}, + {"parseInteger($s, 'x')", "E005"}, + {"pow($s, 2)", "E005"}, + {"floor($s)", "E005"}, + {"round(true)", "E005"}, + {"sqrt($b)", "E005"}, + {"addDays($dt, $b)", "E005"}, + {"abs($dt)", "E005"}, + {"addDays($dt, $dt)", "E005"}, + {"min($d, 'x')", "E005"}, + {"max($b, $b)", "E005"}, + + // Decimal → Integer/Long is refused; every other numeric pairing passes. + {"addDays($dt, 1.5)", "E005"}, + {"addDays($dt, $d)", "E005"}, + {"subtractDays($dt, 1.5)", "E005"}, + {"addMonthsUTC($dt, 1.5)", "E005"}, + {"substring('abc', $d)", "E005"}, + {"parseInteger($s, 1.5)", "E005"}, + {"round($d, $d)", "E005"}, + {"dateTime(2020.5, 1, 1)", "E005"}, + {"epochToDateTime($d)", "E005"}, + {"addDays($dt, $l)", ""}, + {"addSeconds($dt, $l)", ""}, + {"substring($s, $l)", ""}, + {"round($d, $l)", ""}, + {"parseInteger($s, $l)", ""}, + {"epochToDateTime($i)", ""}, + {"epochToDateTime($l)", ""}, + {"abs($l)", ""}, + {"floor($i)", ""}, + {"sqrt($i)", ""}, + {"pow($i, $l)", ""}, + + // String / number → DateTime + {"addDays('2020-01-01', 1)", "E005"}, + {"formatDateTime($s, 'yyyy')", "E005"}, + {"dateTimeToEpoch($s)", "E005"}, + {"millisecondsBetween($s, $dt)", "E005"}, + {"daysBetween($dt, $s)", "E005"}, + {"parseDateTime($s, 'yyyy', 'x')", "E005"}, + {"parseDateTime($s, 'yyyy', $d)", "E005"}, + {"beginOfDay(5)", "E005"}, + + // parseDecimal's second argument is a format OR a default. + {"parseDecimal($s, 2)", ""}, + {"parseDecimal($s, $d)", ""}, + {"parseDecimal($s, '#', $i)", ""}, + {"parseDecimal($s, $s, $l)", ""}, + {"parseDecimal($s, '#', 'x')", "E005"}, + + // Arity rows the signature table had wrong. Each was E006 on the + // project tier; each builds at 0 errors. + {"find($s, 'a', 2)", ""}, + {"findLast($s, 'a', 2)", ""}, + {"find($s, 'a', 'b')", "E005"}, + {"max($i, $i, $i)", ""}, + {"min(1, 2, 3)", ""}, + {"max($i, 1, 2, 3, 4)", ""}, + {"min(1, 2.5, $l)", ""}, + {"max($dt, $dt)", ""}, // max/min take DateTime too + {"min($dt, $dt)", ""}, + {"dateTime(2020)", ""}, + {"dateTime(2020, 1)", ""}, + {"dateTime(2020, 1, 1, 10)", ""}, + {"dateTime(2020, 1, 1, 10, 30)", ""}, + {"dateTimeUTC(2020)", ""}, + {"formatDateTime($dt)", ""}, + {"formatDateTimeUTC($dt)", ""}, + {"formatTime($dt)", ""}, + {"formatDateUTC($dt)", ""}, + {"formatDateTime($dt, 'yyyy', 'en-US')", "E006"}, + {"parseDateTime($s)", "E006"}, + {"substring('abc', 1, 2, 3)", "E006"}, + {"length($s, 2)", "E006"}, + {"random(1)", "E006"}, + {"toString()", "E006"}, + + // Controls the check must not touch. + {"toString($dt)", ""}, + {"parseDateTimeUTC($s, 'yyyy', $dt)", ""}, + {"max($d, $i)", ""}, + } + for _, c := range cases { + t.Run(c.expr, func(t *testing.T) { + hs := BuiltinCallHints(c.expr, builtinProbeVars) + got := "" + if len(hs) > 0 { + got = hs[0].Code + } + if got != c.wantCode { + t.Errorf("BuiltinCallHints(%q) = %q, want %q (hints: %+v)", c.expr, got, c.wantCode, hs) + } + }) + } +} + +// TestBuiltinCallHints_UnknownKindsAreTolerated keeps the check to what it can +// prove: an argument whose kind is not known — an undeclared variable, an +// attribute path without a project, an enumeration, a token — is never an +// error. `empty` is tolerated too even though mxbuild refuses `length(empty)`: +// it is a separate question from a kind mismatch and not this rule's claim. +func TestBuiltinCallHints_UnknownKindsAreTolerated(t *testing.T) { + for _, expr := range []string{ + "formatDecimal($Unknown, 2.0)", // arg 2 is still a number: flagged below + "length($Unknown)", + "length($Order/Name)", + "addDays([%CurrentDateTime%], 1)", + "formatDateTime([%BeginOfCurrentDay%], 'yyyy')", + "length(empty)", + "contains($e, 'x')", + "getCaption($s)", + } { + hs := BuiltinCallHints(expr, map[string]TypeKind{"s": KindString, "e": KindEnumeration}) + if expr == "formatDecimal($Unknown, 2.0)" { + if len(hs) != 1 || hs[0].Code != "E005" { + t.Errorf("%s: want exactly the E005 on the pattern argument, got %+v", expr, hs) + } + continue + } + if len(hs) != 0 { + t.Errorf("%s: want no hint, got %+v", expr, hs) + } + } +} + +// TestBuiltinCallHints_NestedCallTyped checks that a call's result kind feeds +// the enclosing call: toString(minutesBetween(...)) is fine, while +// length(minutesBetween(...)) hands a Decimal to a String parameter. +func TestBuiltinCallHints_NestedCallTyped(t *testing.T) { + if hs := BuiltinCallHints("toString(minutesBetween($a, $b))", map[string]TypeKind{"a": KindDateTime, "b": KindDateTime}); len(hs) != 0 { + t.Errorf("toString of a Decimal must pass, got %+v", hs) + } + hs := BuiltinCallHints("length(minutesBetween($a, $b))", map[string]TypeKind{"a": KindDateTime, "b": KindDateTime}) + if len(hs) != 1 || hs[0].Code != "E005" { + t.Errorf("length() must be E005, got %+v", hs) + } +} diff --git a/mdl/exprcheck/func_checker.go b/mdl/exprcheck/func_checker.go index 82b9d2313c..27c07976db 100644 --- a/mdl/exprcheck/func_checker.go +++ b/mdl/exprcheck/func_checker.go @@ -4,6 +4,7 @@ package exprcheck import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/exprcheck/hints" ) @@ -25,6 +26,29 @@ type funcSig struct { // `abs($int)` and `max($int, 3)` into an Integer are accepted, `abs($dec)` // and `max($int, 3.5)` are CE0117. retFromArgs bool + + // variadic lets the last parameter repeat: max/min take any number of + // operands from two up (`max($i, 1, 2, 3, 4)` builds at 0 errors). + variadic bool + + // alts lists further kinds a parameter accepts beside args[i]. An index + // past the end of args falls on the last parameter, as variadic does. + alts map[int][]TypeKind + + // unverified marks a row nobody has built against mxbuild. Its argument + // kinds are not trusted, so E005 never fires on it. + unverified bool +} + +// accepts returns the kinds parameter i accepts. +func (s funcSig) accepts(i int) []TypeKind { + if len(s.args) == 0 { + return nil + } + if i >= len(s.args) { + i = len(s.args) - 1 + } + return append([]TypeKind{s.args[i]}, s.alts[i]...) } // retFor returns the result kind for a call with argc arguments. @@ -58,9 +82,11 @@ var funcTable = map[string]funcSig{ "isMatch": {args: []TypeKind{KindString, KindString}, ret: KindBoolean}, // String — transforms - "length": {args: []TypeKind{KindString}, ret: KindInteger}, - "find": {args: []TypeKind{KindString, KindString}, ret: KindInteger}, - "findLast": {args: []TypeKind{KindString, KindString}, ret: KindInteger}, + "length": {args: []TypeKind{KindString}, ret: KindInteger}, + // find/findLast(text, fragment [, startIndex]) — the start index builds at + // 0 errors on 11.6.6 and 11.15.0 (mendixlabs/mxcli#1515). + "find": {args: []TypeKind{KindString, KindString, KindInteger}, minArgs: 2, ret: KindInteger}, + "findLast": {args: []TypeKind{KindString, KindString, KindInteger}, minArgs: 2, ret: KindInteger}, "substring": {args: []TypeKind{KindString, KindInteger, KindInteger}, minArgs: 2, ret: KindString}, "trim": {args: []TypeKind{KindString}, ret: KindString}, "toUpperCase": {args: []TypeKind{KindString}, ret: KindString}, @@ -79,10 +105,12 @@ var funcTable = map[string]funcSig{ // default overload: `parseBoolean($s, false)` is CE0117. All measured with // mx check on 11.14.0 (mendixlabs/mxcli#1216). "parseInteger": {args: []TypeKind{KindString, KindInteger}, minArgs: 1, ret: KindInteger}, - "parseDecimal": {args: []TypeKind{KindString, KindString, KindDecimal}, minArgs: 1, ret: KindDecimal}, + "parseDecimal": {args: []TypeKind{KindString, KindString, KindDecimal}, minArgs: 1, ret: KindDecimal, alts: map[int][]TypeKind{1: {KindDecimal}}}, "parseBoolean": {args: []TypeKind{KindString}, ret: KindBoolean}, - // formatDecimal(value [, format [, languageTag]]) — format is optional - "formatDecimal": {args: []TypeKind{KindDecimal, KindString, KindString}, minArgs: 1, ret: KindString}, + // formatDecimal(value, format [, languageTag]). The format is NOT + // optional: `formatDecimal($d)` is CE0117 on 11.6.6 and 11.15.0, as is a + // number in either String slot (mendixlabs/mxcli#1515). + "formatDecimal": {args: []TypeKind{KindDecimal, KindString, KindString}, minArgs: 2, ret: KindString}, // Math. The return kinds here are what Mendix actually accepts in an // Integer/Long target, not what the expression documentation prints: floor, @@ -93,14 +121,18 @@ var funcTable = map[string]funcSig{ // There is no `trunc`. It used to be treated as a rounding function here; // Mendix has no such built-in and `trunc($d)` fails the build with CE0117, so // MDL044 flagging it is correct and adding it to this table would be wrong. - "abs": {args: []TypeKind{KindDecimal}, ret: KindDecimal, retFromArgs: true}, - "round": {args: []TypeKind{KindDecimal, KindInteger}, minArgs: 1, ret: KindDecimal, retByArity: map[int]TypeKind{1: KindInteger}}, - "floor": {args: []TypeKind{KindDecimal}, ret: KindInteger}, - "ceil": {args: []TypeKind{KindDecimal}, ret: KindInteger}, - "pow": {args: []TypeKind{KindDecimal, KindDecimal}, ret: KindDecimal}, - "sqrt": {args: []TypeKind{KindDecimal}, ret: KindDecimal}, - "max": {args: []TypeKind{KindDecimal, KindDecimal}, ret: KindDecimal, retFromArgs: true}, - "min": {args: []TypeKind{KindDecimal, KindDecimal}, ret: KindDecimal, retFromArgs: true}, + "abs": {args: []TypeKind{KindDecimal}, ret: KindDecimal, retFromArgs: true}, + "round": {args: []TypeKind{KindDecimal, KindInteger}, minArgs: 1, ret: KindDecimal, retByArity: map[int]TypeKind{1: KindInteger}}, + "floor": {args: []TypeKind{KindDecimal}, ret: KindInteger}, + "ceil": {args: []TypeKind{KindDecimal}, ret: KindInteger}, + "pow": {args: []TypeKind{KindDecimal, KindDecimal}, ret: KindDecimal}, + "sqrt": {args: []TypeKind{KindDecimal}, ret: KindDecimal}, + // max/min take two or more operands, numbers or DateTimes (`max($dt, $dt)` + // and `min(1, 2.5, $l)` build at 0 errors on 11.6.6 and 11.15.0; a String + // or Boolean operand is CE0117). A single-argument max/min is not this + // function at all: the visitor reads it as the MAXIMUM/MINIMUM aggregate. + "max": {args: []TypeKind{KindDecimal, KindDecimal}, ret: KindDecimal, retFromArgs: true, variadic: true, alts: map[int][]TypeKind{0: {KindDateTime}, 1: {KindDateTime}}}, + "min": {args: []TypeKind{KindDecimal, KindDecimal}, ret: KindDecimal, retFromArgs: true, variadic: true, alts: map[int][]TypeKind{0: {KindDateTime}, 1: {KindDateTime}}}, "random": {args: []TypeKind{}, ret: KindDecimal}, // Enumerations @@ -120,9 +152,11 @@ var funcTable = map[string]funcSig{ // DateTime — construction. There is no currentDateTime(): the current time // is the [%CurrentDateTime%] token (see tokenFuncs in unknown_funcs.go). - // dateTime/dateTimeUTC(year, month, day [, hour, minute, second]) — 3 or 6 args - "dateTime": {args: []TypeKind{KindInteger, KindInteger, KindInteger, KindInteger, KindInteger, KindInteger}, minArgs: 3, ret: KindDateTime}, - "dateTimeUTC": {args: []TypeKind{KindInteger, KindInteger, KindInteger, KindInteger, KindInteger, KindInteger}, minArgs: 3, ret: KindDateTime}, + // dateTime/dateTimeUTC(year [, month, day, hour, minute, second]): every + // count from 1 to 6 builds at 0 errors on 11.6.6 and 11.15.0 + // (mendixlabs/mxcli#1515). + "dateTime": {args: []TypeKind{KindInteger, KindInteger, KindInteger, KindInteger, KindInteger, KindInteger}, minArgs: 1, ret: KindDateTime}, + "dateTimeUTC": {args: []TypeKind{KindInteger, KindInteger, KindInteger, KindInteger, KindInteger, KindInteger}, minArgs: 1, ret: KindDateTime}, // DateTime — add (local calendar) "addMilliseconds": {args: []TypeKind{KindDateTime, KindInteger}, ret: KindDateTime}, @@ -201,7 +235,9 @@ var funcTable = map[string]funcSig{ "trimToYearsUTC": {args: []TypeKind{KindDateTime}, ret: KindDateTime}, // DateTime — formatting / parsing (local calendar) - "formatDateTime": {args: []TypeKind{KindDateTime, KindString}, ret: KindString}, + // The format is optional for all three (1 argument builds at 0 errors on + // 11.6.6 and 11.15.0; a third is CE0117). + "formatDateTime": {args: []TypeKind{KindDateTime, KindString}, minArgs: 1, ret: KindString}, "formatTime": {args: []TypeKind{KindDateTime, KindString}, minArgs: 1, ret: KindString}, "formatDate": {args: []TypeKind{KindDateTime, KindString}, minArgs: 1, ret: KindString}, // parseDateTime/parseDateTimeUTC(value, format [, default]) — the default is @@ -210,7 +246,7 @@ var funcTable = map[string]funcSig{ "parseDateTime": {args: []TypeKind{KindString, KindString, KindDateTime}, minArgs: 2, ret: KindDateTime}, // DateTime — formatting / parsing (UTC calendar) - "formatDateTimeUTC": {args: []TypeKind{KindDateTime, KindString}, ret: KindString}, + "formatDateTimeUTC": {args: []TypeKind{KindDateTime, KindString}, minArgs: 1, ret: KindString}, "formatTimeUTC": {args: []TypeKind{KindDateTime, KindString}, minArgs: 1, ret: KindString}, "formatDateUTC": {args: []TypeKind{KindDateTime, KindString}, minArgs: 1, ret: KindString}, "parseDateTimeUTC": {args: []TypeKind{KindString, KindString, KindDateTime}, minArgs: 2, ret: KindDateTime}, @@ -226,42 +262,49 @@ var funcTable = map[string]funcSig{ // (`parseInteger(formatDateTime($d, 'yyyy'))`). The sibling extraction names // below are UNVERIFIED against a real Mendix build and are likely invalid too; // remove any that a build rejects (do not add more without confirming against - // Studio Pro — this whole family looks speculative). - "dayOfYear": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "dayOfMonth": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "weekOfYear": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "dayOfWeek": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "hour": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "minute": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "second": {args: []TypeKind{KindDateTime}, ret: KindInteger}, - "millisecond": {args: []TypeKind{KindDateTime}, ret: KindInteger}, + // Studio Pro — this whole family looks speculative). Measured since: every + // row below, dateTimeToDate included, is CE0117 on 11.6.6 and 11.15.0 with + // a DateTime argument (mendixlabs/mxcli#1515), so these are not built-ins. + // Removing them turns them into MDL044 errors, a change of its own; until + // then they are marked unverified and E005 stays off them. + "dayOfYear": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "dayOfMonth": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "weekOfYear": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "dayOfWeek": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "hour": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "minute": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "second": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, + "millisecond": {args: []TypeKind{KindDateTime}, ret: KindInteger, unverified: true}, // DateTime — extraction (legacy alias) - "dateTimeToDate": {args: []TypeKind{KindDateTime}, ret: KindDateTime}, + "dateTimeToDate": {args: []TypeKind{KindDateTime}, ret: KindDateTime, unverified: true}, } -// checkCallExpr returns hints for arity / arg-type mismatches against -// the built-in function signature table. User-defined microflow calls -// (CallExpr.Name containing a dot) are not checked here — they require -// catalog lookup. +// checkCallExpr returns hints for arity (E006) and argument-kind (E005) +// mismatches against the built-in function signature table. User-defined +// microflow calls (CallExpr.Name containing a dot) are not checked here — they +// require catalog lookup. func checkCallExpr(c *CallExpr, ctx Context) []Hint { sig, ok := funcTable[c.Name] if !ok { return nil } - var out []Hint got := len(c.Args) minA, maxA := sig.min(), len(sig.args) - if got < minA || got > maxA { + if got < minA || (!sig.variadic && got > maxA) { var expectStr, fixStr string - if minA == maxA { + switch { + case sig.variadic: + expectStr = fmt.Sprintf("at least %d", minA) + fixStr = fmt.Sprintf("Provide at least %d argument(s) for %s().", minA, c.Name) + case minA == maxA: expectStr = fmt.Sprintf("%d", maxA) fixStr = fmt.Sprintf("Provide %d argument(s) for %s().", maxA, c.Name) - } else { + default: expectStr = fmt.Sprintf("%d to %d", minA, maxA) fixStr = fmt.Sprintf("Provide %d to %d argument(s) for %s().", minA, maxA, c.Name) } - out = append(out, Hint{ + return []Hint{{ Code: "E006", Slug: "func-arg-arity", Severity: hints.SeverityError, Where: hints.Location{ @@ -277,11 +320,126 @@ func checkCallExpr(c *CallExpr, ctx Context) []Hint { FunctionReturns: typeKindName(sig.ret), FunctionArgs: typeKindNames(sig.args), }, + }} + } + if sig.unverified { + return nil + } + var out []Hint + for i, a := range c.Args { + k := argKind(a, ctx) + want := sig.accepts(i) + if argAccepted(want, k) { + continue + } + wantStr := kindList(want) + out = append(out, Hint{ + Code: "E005", Slug: "func-arg-type", + Severity: hints.SeverityError, + Where: hints.Location{ + File: ctx.File, Line: a.Pos().Line, Column: a.Pos().Column, + Microflow: ctx.Microflow, + Context: fmt.Sprintf("argument %d of %s()", i+1, c.Name), + }, + YouWrote: fmt.Sprintf("%s(...) with a %s as argument %d", c.Name, typeKindName(k), i+1), + Problem: fmt.Sprintf("%s() argument %d must be %s, but this expression is %s — "+ + "the build fails CE0117 \"Error(s) in expression\".", c.Name, i+1, wantStr, withArticle(typeKindName(k))), + Fix: argFix(want, k), + Reference: &hints.Reference{ + FunctionName: c.Name, + FunctionReturns: typeKindName(sig.ret), + FunctionArgs: typeKindNames(sig.args), + }, }) } return out } +// argKind is the kind of a call argument as far as E005 is concerned. +// +// A [%Token%] is the one place inferKind is too coarse: it calls every token a +// String, while [%CurrentDateTime%] and the [%BeginOf…%]/[%EndOf…%] family are +// DateTimes and [%CurrentUser%] is an object. Rather than change what every +// other rule sees, the argument check types the date tokens and tolerates the +// rest. +func argKind(e RobustExpr, ctx Context) TypeKind { + if t, ok := unwrapParens(e).(*TokenExpr); ok { + name := strings.TrimSuffix(t.Token, "UTC") + if name == "CurrentDateTime" || strings.HasPrefix(name, "BeginOf") || strings.HasPrefix(name, "EndOf") { + return KindDateTime + } + return KindUnknown + } + return inferKind(e, ctx) +} + +// argAccepted reports whether an argument of kind got may stand where one of +// want is expected. It only refuses what it can prove: an argument of unknown +// kind, `empty`, an enumeration, an object, a list or a binary is never +// refused, nor is a parameter typed Any. +// +// Numbers follow what mxbuild does, measured on 11.6.6 and 11.15.0 +// (mendixlabs/mxcli#1515): Integer and Long are interchangeable and both widen +// to Decimal, but a Decimal where an Integer or Long is expected is CE0117 — +// `addDays($dt, 1.5)`, `substring($s, $d)`, `round($d, $d)`. +func argAccepted(want []TypeKind, got TypeKind) bool { + switch got { + case KindBoolean, KindString, KindInteger, KindLong, KindDecimal, KindDateTime: + default: + return true + } + if len(want) == 0 { + return true + } + for _, w := range want { + switch { + case w == KindAny || w == KindUnknown || w == got: + return true + case w == KindDecimal && (got == KindInteger || got == KindLong): + return true + case (w == KindInteger || w == KindLong) && (got == KindInteger || got == KindLong): + return true + case w != KindBoolean && w != KindString && w != KindInteger && + w != KindLong && w != KindDecimal && w != KindDateTime: + // A parameter of a kind this check does not model (Enumeration, + // Object, …): nothing to compare against. + return true + } + } + return false +} + +func kindList(ks []TypeKind) string { + names := typeKindNames(ks) + for i, n := range names { + names[i] = withArticle(n) + } + return strings.Join(names, " or ") +} + +func withArticle(name string) string { + if name != "" && strings.ContainsRune("AEIOU", rune(name[0])) { + return "an " + name + } + return "a " + name +} + +func argFix(want []TypeKind, got TypeKind) string { + for _, w := range want { + switch { + case w == KindString: + return "Pass a String: quote a literal, or convert the value with toString()." + case (w == KindInteger || w == KindLong) && got == KindDecimal: + return "Pass a whole number: round the value with round(), floor() or ceil()." + case w == KindInteger || w == KindLong || w == KindDecimal: + return "Pass a number: write a numeric literal, or convert text with parseInteger()/parseDecimal()." + case w == KindDateTime: + return "Pass a DateTime: a DateTime variable, [%CurrentDateTime%], or parseDateTime() for text." + } + } + return "Pass an argument of the kind the function's signature lists." +} + func typeKindName(k TypeKind) string { switch k { case KindBoolean: @@ -328,7 +486,9 @@ func KindName(k TypeKind) string { return typeKindName(k) } type PublicFuncSig struct { Args []string MinArgs int // 0 means all Args are required - Returns string + // Variadic means the last of Args may repeat: there is no maximum. + Variadic bool + Returns string } // PublicFuncTable returns a JSON-friendly view of the built-in function @@ -337,9 +497,10 @@ func PublicFuncTable() map[string]PublicFuncSig { out := make(map[string]PublicFuncSig, len(funcTable)) for k, v := range funcTable { out[k] = PublicFuncSig{ - Args: typeKindNames(v.args), - MinArgs: v.minArgs, - Returns: typeKindName(v.ret), + Args: typeKindNames(v.args), + MinArgs: v.minArgs, + Variadic: v.variadic, + Returns: typeKindName(v.ret), } } return out diff --git a/mdl/exprcheck/func_checker_test.go b/mdl/exprcheck/func_checker_test.go index 54a22bd826..b7db630485 100644 --- a/mdl/exprcheck/func_checker_test.go +++ b/mdl/exprcheck/func_checker_test.go @@ -349,7 +349,7 @@ func TestFuncChecker_FormatDecimal_WrongArity_E006(t *testing.T) { p := NewParser() _, hs := p.Parse("formatDecimal()", Context{Microflow: "M.F"}) if !hasCode(hs, "E006") { - t.Error("formatDecimal() with 0 args must fire E006 (requires at least 1)") + t.Error("formatDecimal() with 0 args must fire E006 (requires at least 2)") } } diff --git a/mdl/exprcheck/infer.go b/mdl/exprcheck/infer.go index e155cb446b..5d04cbfe97 100644 --- a/mdl/exprcheck/infer.go +++ b/mdl/exprcheck/infer.go @@ -27,6 +27,32 @@ func InferSourceKind(src string, vars map[string]TypeKind) TypeKind { return inferKind(root, ctx) } +// BuiltinCallHints parses src and returns the built-in signature hints — E006 +// (wrong argument count) and E005 (an argument of the wrong kind) — for every +// built-in call in it, typing variables from vars (names without the '$'). +// +// It is the no-project entry point: what it can type is literals, the variables +// in vars and the result kinds of nested built-in calls. Anything else infers +// Unknown and is never refused; the catalog-backed tier, which can type +// attribute paths, runs the same checkCallExpr with more to go on. +func BuiltinCallHints(src string, vars map[string]TypeKind) []Hint { + if strings.TrimSpace(src) == "" { + return nil + } + ctx := Context{} + if vars != nil { + ctx.Scope = mapScope(vars) + } + _, hs := (&parserImpl{}).Parse(src, ctx) + var out []Hint + for _, h := range hs { + if h.Code == "E005" || h.Code == "E006" { + out = append(out, h) + } + } + return out +} + // SourceIsArithmeticDecimal reports whether src is a bare arithmetic expression // whose result kind is Decimal — the case that fails mx check when assigned to // an Integer/Long variable (CE0117), most commonly `$a * 100 div $b`. From 81be179db6ebdfa21e86ea804ea950bff506b5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:50:55 +0000 Subject: [PATCH 17/25] fix(run-local): boot with scheduled events running and say so (mendixlabs/mxcli#1528) run --local never set ScheduledEventExecution in the boot update_configuration payload, so the runtime's own default NONE applied: an app's scheduled events silently never fired, the only trace being a "Synchronizing scheduled events: None" runtime log line. RunLocal now defaults ScheduledEventExecution=ALL after building the runtime settings, unless the user passed --runtime-setting ScheduledEventExecution=..., which still wins. It prints one line at boot naming the effective value, the number of scheduled events in the model, and how to override it. The shared runtimeConfigParams payload is unchanged, so the test runner (StartLocalApp) keeps NONE and test runs stay deterministic; a test pins that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...n-local-scheduled-events-silently-off.json | 1 + .claude/skills/mendix/run-local/SKILL.md | 29 +++++----- cmd/mxcli/cmd_run.go | 4 +- cmd/mxcli/docker/runlocal.go | 51 +++++++++++++++++ cmd/mxcli/docker/runlocal_test.go | 55 +++++++++++++++++++ 5 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-run-local-scheduled-events-silently-off.json diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-run-local-scheduled-events-silently-off.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-run-local-scheduled-events-silently-off.json new file mode 100644 index 0000000000..06321b4d54 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-run-local-scheduled-events-silently-off.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-16", "symptom": "`mxcli run --local` booted an app whose scheduled events never fired; the only trace was the runtime log line `Synchronizing scheduled events: None`, and nothing at start said so", "cause": "`runtimeConfigParams` builds the boot update_configuration payload without `ScheduledEventExecution`, so the runtime's own default NONE applies to every local run", "file": "`cmd/mxcli/docker/runlocal.go` (`withScheduledEventsDefault`, `scheduledEventsLine`, called in `RunLocal` after `buildRuntimeSettings`)", "insight": "A setting mxcli never names is a setting the runtime decides, silently. The default belongs in the RunLocal path, not in `runtimeConfigParams`: that payload is shared with the test runner (`StartLocalApp`), where a scheduled event firing mid-run would make tests nondeterministic, and a test pins that the shared payload stays without it. Explicit `--runtime-setting` still wins because the default is only added when the key is absent. Not booted; asserted on the payload map", "refs": ["mendixlabs/mxcli#1528"]} diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 626ca0859d..ccb7296a37 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -19,20 +19,20 @@ description: "The warm Docker-free dev loop, `mxcli run --local` — model chang | 214-238 | Editing themes (SCSS) — rebuild, never clear caches | | 239-265 | `--watch` waits for a write to finish before it builds | | 266-294 | "My edit didn't show up" — stale process, not stale cache | -| 295-323 | Flags | -| 324-377 | Metrics and OpenTelemetry | -| 378-435 | Pages render in the browser | -| 436-453 | Pixel-perfect page loop | -| 454-474 | Debugging a server-side error | -| 475-495 | "Sign in failed" that is not about the password | -| 496-528 | The same licence also stops the app after a few hours | -| 529-608 | External browser preview (`--hub`) | -| 609-617 | Validation checklist | -| 618-657 | Constant values come from a configuration | -| 658-661 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | -| 662-665 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | -| 666-669 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | -| 670-672 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | +| 295-324 | Flags | +| 325-378 | Metrics and OpenTelemetry | +| 379-436 | Pages render in the browser | +| 437-454 | Pixel-perfect page loop | +| 455-475 | Debugging a server-side error | +| 476-496 | "Sign in failed" that is not about the password | +| 497-529 | The same licence also stops the app after a few hours | +| 530-609 | External browser preview (`--hub`) | +| 610-618 | Validation checklist | +| 619-658 | Constant values come from a configuration | +| 659-662 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | +| 663-666 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | +| 667-670 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | +| 671-673 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | # Warm Local Dev Loop — `mxcli run --local` @@ -320,6 +320,7 @@ run's process session), and `run status` reports them. Do **not** | `--trace-service` | `.mpr` name | `OTEL_SERVICE_NAME` under `--trace` | | `--trace-otlp` | off (console) | Export traces to this OTLP collector endpoint (e.g. `http://127.0.0.1:4318`) instead of the console. Implies `--trace`. Needed for flame charts | | `--runtime-setting Key=Value` | — | Merge an extra runtime setting into the boot config (Value parsed as JSON when possible). Repeatable. | +| (scheduled events) | `ALL` | `run --local` boots with `ScheduledEventExecution=ALL` (the runtime's own default is NONE, which silently never fires an event) and prints `Scheduled events: ALL (N defined)` at boot. `--runtime-setting ScheduledEventExecution=NONE` (or `SPECIFIED` plus `MyScheduledEvents`) wins. The test runner keeps NONE | ## Metrics and OpenTelemetry diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index 481e13fd06..fd30a63041 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -99,7 +99,9 @@ console exporter omits timestamps and parent span IDs, so for flame charts pass instead. Use --runtime-setting Key=Value (repeatable) to merge any other runtime setting into the boot config (the admin config action replaces rather than merges, so mxcli folds these into its single boot call), e.g. a different Metrics.Registries type or -custom OpenTelemetry span filters. +custom OpenTelemetry span filters. Scheduled events run (ScheduledEventExecution=ALL, +where the runtime's own default is NONE); --runtime-setting ScheduledEventExecution=NONE +turns them off. Examples: mxcli run --local -p app.mpr diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 22f315f308..c8e813e14a 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -295,6 +295,54 @@ func buildRuntimeSettings(metrics, trace bool, raw []string) (map[string]any, er return out, nil } +// scheduledEventExecutionKey is the runtime setting that decides which +// scheduled events run. The runtime's own default is NONE. +const scheduledEventExecutionKey = "ScheduledEventExecution" + +// withScheduledEventsDefault makes run --local boot with scheduled events +// running (mendixlabs/mxcli#1528). Without it the runtime default NONE applies +// and an app's scheduled events silently never fire; the only trace is a +// "Synchronizing scheduled events: None" runtime log line. An explicit +// --runtime-setting ScheduledEventExecution=... wins. It returns the settings, +// the effective value, and whether the default was applied. +// +// Only RunLocal calls this: the test runner (StartLocalApp) keeps the runtime +// default so a scheduled event cannot fire in the middle of a test run. +func withScheduledEventsDefault(settings map[string]any) (map[string]any, string, bool) { + if v, ok := settings[scheduledEventExecutionKey]; ok { + return settings, fmt.Sprint(v), false + } + if settings == nil { + settings = map[string]any{} + } + settings[scheduledEventExecutionKey] = "ALL" + return settings, "ALL", true +} + +// scheduledEventsLine is the start-summary line naming the scheduled-event +// setting. count < 0 means the count is unknown and is left out. +func scheduledEventsLine(value string, defaulted bool, count int) string { + line := "Scheduled events: " + value + if count >= 0 { + line += fmt.Sprintf(" (%d defined)", count) + } + if defaulted { + line += " — override with --runtime-setting ScheduledEventExecution=NONE" + } else { + line += " (from --runtime-setting)" + } + return line +} + +// countScheduledEvents counts the model's scheduled events; -1 when unreadable. +func countScheduledEvents(reader backend.FullBackend) int { + events, err := reader.ListScheduledEvents() + if err != nil { + return -1 + } + return len(events) +} + // parseRuntimeSetting splits "Key=Value"; Value is parsed as JSON when possible // (so arrays/objects/numbers/bools pass through typed), else kept as a string. func parseRuntimeSetting(s string) (string, any, error) { @@ -649,6 +697,7 @@ func RunLocal(opts LocalRunOptions) error { // separate step; collect them here so the boot can vendor any that are // missing (mxcli-formula1 findings #12). declaredJars := declaredJarDependencies(reader) + scheduledEventCount := countScheduledEvents(reader) reader.Disconnect() version := pv.ProductVersion fmt.Fprintf(w, " Mendix version: %s\n", version) @@ -816,6 +865,8 @@ func RunLocal(opts LocalRunOptions) error { if err != nil { return err } + runtimeSettings, schedValue, schedDefaulted := withScheduledEventsDefault(runtimeSettings) + fmt.Fprintln(w, scheduledEventsLine(schedValue, schedDefaulted, scheduledEventCount)) warnWildcardWithoutRootURL(stderr, opts.ListenAddr, opts.AppPort, appRootURL, runtimeSettings) traceService := opts.TraceService if opts.Trace && traceService == "" { diff --git a/cmd/mxcli/docker/runlocal_test.go b/cmd/mxcli/docker/runlocal_test.go index d4efe98bf6..9eaf65dc25 100644 --- a/cmd/mxcli/docker/runlocal_test.go +++ b/cmd/mxcli/docker/runlocal_test.go @@ -645,3 +645,58 @@ func TestDBConfig_IsFileBased(t *testing.T) { t.Error("the check must use the runtime spelling set by applyDatabaseDefaults, not the raw flag") } } + +// run --local must boot with scheduled events running (mendixlabs/mxcli#1528): +// the runtime's own default for ScheduledEventExecution is NONE, so a dev loop +// that never sets it silently never fires a scheduled event. +func TestRunLocalScheduledEventsDefault(t *testing.T) { + // Nothing requested → ALL is added, and it reaches the boot payload. + s, val, defaulted := withScheduledEventsDefault(nil) + if !defaulted || val != "ALL" { + t.Fatalf("got value %q defaulted=%v, want ALL defaulted", val, defaulted) + } + params := runtimeConfigParams(LocalRuntimeOptions{RuntimeSettings: s}, nil) + if params["ScheduledEventExecution"] != "ALL" { + t.Errorf("boot payload ScheduledEventExecution = %v, want ALL", params["ScheduledEventExecution"]) + } + + // Other settings are kept alongside the default. + in, _ := buildRuntimeSettings(true, false, nil) + s, _, _ = withScheduledEventsDefault(in) + if s["Metrics.Registries"] == nil || s["ScheduledEventExecution"] != "ALL" { + t.Errorf("want metrics registry kept and ALL added, got %v", s) + } + + // An explicit --runtime-setting wins. + in, _ = buildRuntimeSettings(false, false, []string{"ScheduledEventExecution=NONE"}) + s, val, defaulted = withScheduledEventsDefault(in) + if defaulted || val != "NONE" || s["ScheduledEventExecution"] != "NONE" { + t.Errorf("explicit NONE must win, got value %q defaulted=%v settings %v", val, defaulted, s) + } + params = runtimeConfigParams(LocalRuntimeOptions{RuntimeSettings: s}, nil) + if params["ScheduledEventExecution"] != "NONE" { + t.Errorf("boot payload ScheduledEventExecution = %v, want NONE", params["ScheduledEventExecution"]) + } +} + +// The shared boot payload (also used by the test runner) must not grow the +// default: only RunLocal opts in, so test runs stay deterministic. +func TestRuntimeConfigParamsHasNoScheduledEventsDefault(t *testing.T) { + params := runtimeConfigParams(LocalRuntimeOptions{}, nil) + if _, ok := params["ScheduledEventExecution"]; ok { + t.Errorf("runtimeConfigParams must not set ScheduledEventExecution itself, got %v", params["ScheduledEventExecution"]) + } +} + +func TestScheduledEventsLine(t *testing.T) { + got := scheduledEventsLine("ALL", true, 3) + for _, want := range []string{"Scheduled events: ALL", "3 defined", "--runtime-setting ScheduledEventExecution=NONE"} { + if !strings.Contains(got, want) { + t.Errorf("line %q missing %q", got, want) + } + } + got = scheduledEventsLine("NONE", false, -1) + if !strings.Contains(got, "Scheduled events: NONE") || strings.Contains(got, "defined") { + t.Errorf("explicit NONE with unknown count: %q", got) + } +} From 976d4d762d0f10a933083a8524e060fadd40e7ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:03:24 +0000 Subject: [PATCH 18/25] fix(marketplace): run the widget and design-property repairs as part of install (mendixlabs/mxcli#1527) A headless module or widget install leaves CE0463 (stored widget instances older than the installed packages) and, for a module, CE6087 (renamed design properties) for Mendix's own tools to fix. install only printed 'mxcli fix widgets' / 'mxcli fix design-properties' as a Next step, so the field run was left with 41 units of CE0463. install now runs them itself through docker.RunToolPreservingFormat, the same call 'mxcli fix' makes, which keeps MPR v2: both repairs after a transplant module install, the widget resync after a widget install (online and --file). When the project's Mendix version cannot be read or mx cannot be resolved, it prints a warning and the manual commands instead of failing; a failing repair names its own command and the next one still runs. The mx resolution and tool runner are package-level seams so the tests need no mx and no marketplace network. Not changed: marketplace update (still prints the steps), the --allow-format-change module-import path, and dependency resolution. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...-marketplace-install-runs-fix-widgets.json | 1 + .../download-marketplace-content/SKILL.md | 21 ++- cmd/mxcli/cmd_marketplace_install.go | 57 ++++--- cmd/mxcli/marketplace_postinstall.go | 106 +++++++++++++ cmd/mxcli/marketplace_postinstall_test.go | 147 ++++++++++++++++++ docs-site/src/guides/marketplace.md | 4 + 6 files changed, 305 insertions(+), 31 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-marketplace-install-runs-fix-widgets.json create mode 100644 cmd/mxcli/marketplace_postinstall.go create mode 100644 cmd/mxcli/marketplace_postinstall_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-marketplace-install-runs-fix-widgets.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-marketplace-install-runs-fix-widgets.json new file mode 100644 index 0000000000..401c1a8182 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-16-marketplace-install-runs-fix-widgets.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-16", "symptom": "`mxcli marketplace install ` of a module or widget that ships pluggable widgets left the project failing CE0463 (41 units in the field run) until the user ran `mxcli fix widgets` by hand; install only printed it as a \"Next\" step", "cause": "`installModuleFromFile` (transplant path) and the widget paths (`installWidget`, `placeWidgetFile` via `printWidgetNext`) printed the `mxcli fix widgets` / `mxcli fix design-properties` commands instead of running them", "file": "`cmd/mxcli/marketplace_postinstall.go` (`runPostInstallRepairs`, seams `postInstallResolveMx` / `postInstallRunTool` / `postInstallMendixVersion`); `cmd/mxcli/cmd_marketplace_install.go` (`reportTransplantInstall`, `resyncInstalledWidget`)", "insight": "A step every install needs and only one tool can do belongs in the install, not in its output: a printed \"Next\" is skipped by agents and humans alike. The repair reuses `docker.RunToolPreservingFormat` (what `mxcli fix` calls), so MPR v2 survives; bare `mx update-widgets` would collapse it. Unresolvable mx (or an unreadable version) keeps the printed commands as a warning, never a failure, and a failing step names its own manual command while the next still runs. Measured on 11.6.6 with `install --file` of a widget: update-widgets ran, 369 .mxunit files before and after. Not changed: `marketplace update` (still prints the steps) and the --allow-format-change module-import path; dependency resolution is a separate concern", "refs": ["mendixlabs/mxcli#1527"]} diff --git a/.claude/skills/mendix/download-marketplace-content/SKILL.md b/.claude/skills/mendix/download-marketplace-content/SKILL.md index 1ef0b887d1..67aa31f7fb 100644 --- a/.claude/skills/mendix/download-marketplace-content/SKILL.md +++ b/.claude/skills/mendix/download-marketplace-content/SKILL.md @@ -15,14 +15,14 @@ description: "The full lifecycle of Mendix Marketplace modules and widgets from | 60-93 | Step 1 — Discover | | 94-103 | Step 2 — Download a `.mpk` to disk (optional) | | 104-223 | Step 3 — Install into a project | -| 224-283 | Step 4 — Repair the model after the install (required, headless) | -| 284-340 | Step 5 — Before updating: has the module been edited? | -| 341-399 | Step 6 — Update an installed module | -| 400-445 | Worked example: the agent-editor stack on a vanilla app | -| 446-453 | Notes | -| 454-457 | Platform authentication (`mxcli auth login/logout/status/list`) with PAT scheme for marketplace-api | -| 458-461 | Marketplace download/install (`mxcli marketplace download/install`) — the content API now exposes a per-version downloadUrl (303→public CDN); install is type-aware (widget→widgets/, new module→`mx module-import`); existing-module updates are reported, not applied (entity-ID/local-edit safety — see PROPOSAL_marketplace_modules | -| 462-466 | Marketplace drift detection (`mxcli marketplace diff -p app.mpr [--to VERSION] [--json]`) | +| 224-288 | Step 4 — Repair the model after the install (required, headless) | +| 289-345 | Step 5 — Before updating: has the module been edited? | +| 346-404 | Step 6 — Update an installed module | +| 405-450 | Worked example: the agent-editor stack on a vanilla app | +| 451-458 | Notes | +| 459-462 | Platform authentication (`mxcli auth login/logout/status/list`) with PAT scheme for marketplace-api | +| 463-466 | Marketplace download/install (`mxcli marketplace download/install`) — the content API now exposes a per-version downloadUrl (303→public CDN); install is type-aware (widget→widgets/, new module→`mx module-import`); existing-module updates are reported, not applied (entity-ID/local-edit safety — see PROPOSAL_marketplace_modules | +| 467-471 | Marketplace drift detection (`mxcli marketplace diff -p app.mpr [--to VERSION] [--json]`) | # Download, Install and Update Marketplace Content @@ -223,6 +223,11 @@ Commit first: local edits to that module go without being named. ## Step 4 — Repair the model after the install (required, headless) +`marketplace install` runs these repairs itself (widgets: the widget resync only) when +it can resolve `mx` for the project's version, and prints a per-repair result line. +Without `mx` it prints a warning and the commands below — run them yourself, and +always after `marketplace update`, which still only prints them: + ```bash mxcli fix widgets -p app.mpr # clears CE0463 mxcli fix design-properties -p app.mpr # clears CE6087 diff --git a/cmd/mxcli/cmd_marketplace_install.go b/cmd/mxcli/cmd_marketplace_install.go index 4a71e2a2eb..8477bf99cb 100644 --- a/cmd/mxcli/cmd_marketplace_install.go +++ b/cmd/mxcli/cmd_marketplace_install.go @@ -50,6 +50,12 @@ installed alongside the model. --allow-format-change selects the legacy module-import path instead. +After a module or widget install, the repairs a headless install leaves for +Mendix's own tools run automatically — the widget resync (CE0463) and, for a +module, the design-property rename (CE6087), the same steps as 'mxcli fix +widgets' / 'mxcli fix design-properties', which keep MPR v2. When mx for the +project's version cannot be resolved, those commands are printed instead. + --file installs a package from disk through the same writer, with no marketplace lookup and no PAT: the kind (module or widget) is read from the package's own package.xml. That is the route for a module distributed as a file @@ -137,7 +143,7 @@ func runMarketplaceInstall(cmd *cobra.Command, args []string) error { switch strings.ToLower(content.Type) { case "widget": - return installWidget(cmd.Context(), client, version, projDir, out) + return installWidget(cmd.Context(), client, version, mprPath, out) case "module": // Refuse a version the project's Mendix cannot import, before spending a // download and a reference build on it. Only modules go through @@ -178,7 +184,7 @@ func installFromFile(ctx context.Context, mpkPath, mprPath string, allowFormatCh return installModuleFromFile(ctx, mpkPath, mprPath, allowFormatChange, 0, "", "", "from "+filepath.Base(mpkPath), out) case pkg.ClientModule.Name != "": - return placeWidgetFile(mpkPath, filepath.Dir(mprPath), out) + return placeWidgetFile(mpkPath, mprPath, out) } return fmt.Errorf("%s is neither a module nor a widget package: its package.xml names no module and no clientModule", filepath.Base(mpkPath)) @@ -186,8 +192,8 @@ func installFromFile(ctx context.Context, mpkPath, mprPath string, allowFormatCh // installWidget copies the widget .mpk into the project's widgets/ folder. // An existing file with the same name is overwritten (the update path). -func installWidget(ctx context.Context, client *marketplace.Client, v *marketplace.Version, projDir string, out io.Writer) error { - widgetsDir := filepath.Join(projDir, "widgets") +func installWidget(ctx context.Context, client *marketplace.Client, v *marketplace.Version, mprPath string, out io.Writer) error { + widgetsDir := filepath.Join(filepath.Dir(mprPath), "widgets") if err := os.MkdirAll(widgetsDir, 0o755); err != nil { return fmt.Errorf("create widgets dir: %w", err) } @@ -196,14 +202,14 @@ func installWidget(ctx context.Context, client *marketplace.Client, v *marketpla return err } fmt.Fprintf(out, "Installed widget %s into %s\n", v.VersionNumber, dest) - printWidgetNext(out) + resyncInstalledWidget(out, mprPath) return nil } // placeWidgetFile is installWidget for a package already on disk: the same // destination and the same overwrite-on-update rule, without the download. -func placeWidgetFile(mpkPath, projDir string, out io.Writer) error { - widgetsDir := filepath.Join(projDir, "widgets") +func placeWidgetFile(mpkPath, mprPath string, out io.Writer) error { + widgetsDir := filepath.Join(filepath.Dir(mprPath), "widgets") if err := os.MkdirAll(widgetsDir, 0o755); err != nil { return fmt.Errorf("create widgets dir: %w", err) } @@ -216,12 +222,15 @@ func placeWidgetFile(mpkPath, projDir string, out io.Writer) error { return fmt.Errorf("write %s: %w", dest, err) } fmt.Fprintf(out, "Installed widget %s into %s\n", filepath.Base(mpkPath), dest) - printWidgetNext(out) + resyncInstalledWidget(out, mprPath) return nil } -func printWidgetNext(out io.Writer) { - fmt.Fprintln(out, "Run 'mxcli fix widgets -p ' (or reload in Studio Pro) to pick it up.") +// resyncInstalledWidget runs the widget resync a new or updated widget package +// needs before the project's stored instances match it (CE0463) — what +// 'mxcli fix widgets' does. Without mx it prints that command instead. +func resyncInstalledWidget(out io.Writer, mprPath string) { + runPostInstallRepairs(out, out, mprPath, postInstallMendixVersion(mprPath), widgetRepairs) } // installModule imports a module .mpk into the project, but only when the module @@ -278,19 +287,7 @@ func installModuleFromFile(ctx context.Context, mpkPath, mprPath string, allowFo if ierr != nil { return ierr } - fmt.Fprintf(out, "Installed module %q %s into %s\n", - moduleName, label, filepath.Base(mprPath)) - fmt.Fprintf(out, " %d units copied, %d bundled file(s) installed.\n", - res.UnitsCopied, len(res.FilesInstalled)) - reportSkippedFiles(out, res.FilesSkipped) - reportSecurityReconcile(out, res) - // A headless install leaves the model needing two repairs only Mendix's own - // tools can make (CE0463, CE6087). 'mxcli fix' runs them without the v2 -> - // v1 conversion the bare mx commands perform. - fmt.Fprintln(out, "\n Next, repair what a headless install leaves for Studio Pro to finish:") - fmt.Fprintln(out, " mxcli fix widgets -p # CE0463") - fmt.Fprintln(out, " mxcli fix design-properties -p # CE6087") - fmt.Fprintln(out, " mxcli docker check -p ") + reportTransplantInstall(out, mprPath, mendixVer, moduleName, label, res) return nil } @@ -310,6 +307,20 @@ func installModuleFromFile(ctx context.Context, mpkPath, mprPath string, allowFo return nil } +// reportTransplantInstall reports a module copied in by the transplant writer, +// then runs the repairs a headless install leaves (CE0463, CE6087) — the same +// calls 'mxcli fix widgets' / 'mxcli fix design-properties' make, which keep the +// MPR v2 storage format (mendixlabs/mxcli#1527). Without mx it prints them. +func reportTransplantInstall(out io.Writer, mprPath, mendixVer, moduleName, label string, res *mp.UpdateResult) { + fmt.Fprintf(out, "Installed module %q %s into %s\n", + moduleName, label, filepath.Base(mprPath)) + fmt.Fprintf(out, " %d units copied, %d bundled file(s) installed.\n", + res.UnitsCopied, len(res.FilesInstalled)) + reportSkippedFiles(out, res.FilesSkipped) + reportSecurityReconcile(out, res) + runPostInstallRepairs(out, out, mprPath, mendixVer, moduleRepairs) +} + // installByTransplant builds a reference project from the package and copies the // module out of it, so the destination keeps its MPR format. func installByTransplant(ctx context.Context, mpkPath, mprPath, moduleName, mendixVer string, diff --git a/cmd/mxcli/marketplace_postinstall.go b/cmd/mxcli/marketplace_postinstall.go new file mode 100644 index 0000000000..61e4a7babb --- /dev/null +++ b/cmd/mxcli/marketplace_postinstall.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +// marketplace_postinstall.go runs the repairs a headless install leaves for +// Mendix's own tools, as part of `marketplace install` (mendixlabs/mxcli#1527). +// +// A module or widget copied in without Studio Pro leaves stored widget instances +// older than the installed widget packages (CE0463), and a module's design +// properties may use names an older Atlas spelled differently (CE6087). Only +// `mx update-widgets` / `mx rename-design-properties` fix those, and both +// collapse MPR v2 to v1 when run bare. These are the same calls `mxcli fix +// widgets` / `mxcli fix design-properties` make: docker.RunToolPreservingFormat, +// which keeps the storage format. +// +// install used to print the two commands as a "Next" step; the field report was +// 41 units of CE0463 left behind. When mx cannot be resolved the commands are +// still printed — as a warning, never a failure, since the install itself +// succeeded. + +// postInstallRepair is one Mendix repair tool and the mxcli command that runs it. +type postInstallRepair struct { + subcommand string // mx subcommand + what string // what it updates, for output + fixCmd string // the manual equivalent + clears string // the error it clears +} + +var ( + repairWidgets = postInstallRepair{"update-widgets", "widget definitions", "mxcli fix widgets", "CE0463"} + repairDesign = postInstallRepair{"rename-design-properties", "design properties", "mxcli fix design-properties", "CE6087"} + + // moduleRepairs run after a module install; widgetRepairs after a widget + // package lands in widgets/ (a widget ships no design properties to rename). + moduleRepairs = []postInstallRepair{repairWidgets, repairDesign} + widgetRepairs = []postInstallRepair{repairWidgets} +) + +// Seams for tests: resolving mx, running a tool, and reading the project's +// Mendix version. +var ( + postInstallResolveMx = func(mendixVer string) (string, error) { + return docker.ResolveMxForVersion("", mendixVer) + } + postInstallRunTool = docker.RunToolPreservingFormat + postInstallMendixVersion = mendixVersionOf +) + +// runPostInstallRepairs runs each repair on the project. A failure is reported +// with its manual command and the next repair still runs. +func runPostInstallRepairs(out, stderr io.Writer, mprPath, mendixVer string, repairs []postInstallRepair) { + if mendixVer == "" { + fmt.Fprintln(out, "\n Warning: could not read the project's Mendix version, so the post-install repairs did not run.") + printManualRepairs(out, mprPath, repairs) + return + } + mxPath, err := postInstallResolveMx(mendixVer) + if err != nil { + fmt.Fprintf(out, "\n Warning: no mx for Mendix %s (%v), so the post-install repairs did not run.\n", mendixVer, err) + fmt.Fprintf(out, " Run 'mxcli setup mxbuild -p %s', then:\n", mprPath) + printManualRepairs(out, mprPath, repairs) + return + } + + fmt.Fprintln(out, "\n Repairing what a headless install leaves for Studio Pro to finish:") + var failed []postInstallRepair + for _, r := range repairs { + res, err := postInstallRunTool(mxPath, mprPath, r.subcommand, out, stderr) + if err != nil { + fmt.Fprintf(out, " Warning: updating %s (%s) failed: %v\n", r.what, r.clears, err) + failed = append(failed, r) + continue + } + fmt.Fprintf(out, " %s (%s): %s\n", r.what, r.clears, repairSummary(res)) + } + if len(failed) > 0 { + fmt.Fprintln(out, " Retry the failed repair(s):") + printManualRepairs(out, mprPath, failed) + } + fmt.Fprintf(out, "\n Next: mxcli docker check -p %s\n", mprPath) +} + +func repairSummary(res *docker.HarvestResult) string { + if res == nil || !res.Harvested { + return "updated (MPR v1, written natively)" + } + s := fmt.Sprintf("%d unit(s) changed, %d .mxunit file(s) before and after", res.UnitsWritten, res.StorageFiles) + if !res.StillV2 || res.StorageFiles == 0 { + s += " — WARNING: the project is no longer MPR v2; restore it from version control and report this" + } + return s +} + +func printManualRepairs(out io.Writer, mprPath string, repairs []postInstallRepair) { + for _, r := range repairs { + fmt.Fprintf(out, " %s -p %s # %s\n", r.fixCmd, mprPath, r.clears) + } + fmt.Fprintf(out, " mxcli docker check -p %s\n", mprPath) +} diff --git a/cmd/mxcli/marketplace_postinstall_test.go b/cmd/mxcli/marketplace_postinstall_test.go new file mode 100644 index 0000000000..ba044bdf13 --- /dev/null +++ b/cmd/mxcli/marketplace_postinstall_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" + mp "github.com/mendixlabs/mxcli/cmd/mxcli/marketplace" +) + +// A headless install leaves CE0463 (and, for modules, CE6087) until Mendix's own +// repair tools run. install used to only print "Next: mxcli fix widgets"; it +// now runs them itself (mendixlabs/mxcli#1527). These tests stub the mx seam: +// no mx binary, no marketplace network. + +// stubPostInstall replaces the mx resolution and tool runner, recording every +// tool invocation. +func stubPostInstall(t *testing.T, resolveErr error, toolErr map[string]error) *[]string { + t.Helper() + var calls []string + origResolve, origRun := postInstallResolveMx, postInstallRunTool + postInstallResolveMx = func(mendixVer string) (string, error) { + if resolveErr != nil { + return "", resolveErr + } + return "/fake/mx", nil + } + postInstallRunTool = func(mxPath, mprPath, subcommand string, w, stderr io.Writer) (*docker.HarvestResult, error) { + calls = append(calls, subcommand) + if err := toolErr[subcommand]; err != nil { + return nil, err + } + return &docker.HarvestResult{Harvested: true, UnitsWritten: 3, StillV2: true, StorageFiles: 10, StorageFilesBefore: 10}, nil + } + t.Cleanup(func() { postInstallResolveMx, postInstallRunTool = origResolve, origRun }) + return &calls +} + +func TestPostInstallRepairs_ModuleRunsBothTools(t *testing.T) { + calls := stubPostInstall(t, nil, nil) + var out bytes.Buffer + runPostInstallRepairs(&out, &out, "/p/app.mpr", "11.15.0", moduleRepairs) + + if got := strings.Join(*calls, ","); got != "update-widgets,rename-design-properties" { + t.Fatalf("tools run = %q, want update-widgets then rename-design-properties\noutput:\n%s", got, out.String()) + } + s := out.String() + if !strings.Contains(s, "widget definitions") || !strings.Contains(s, "3 unit(s) changed") { + t.Errorf("expected a per-step result line, got:\n%s", s) + } + if strings.Contains(s, "mxcli fix widgets -p") { + t.Errorf("the manual hint must not be printed when the repair ran:\n%s", s) + } +} + +func TestPostInstallRepairs_WidgetRunsOnlyUpdateWidgets(t *testing.T) { + calls := stubPostInstall(t, nil, nil) + var out bytes.Buffer + runPostInstallRepairs(&out, &out, "/p/app.mpr", "11.15.0", widgetRepairs) + if got := strings.Join(*calls, ","); got != "update-widgets" { + t.Fatalf("tools run = %q, want only update-widgets", got) + } +} + +// No mx for the project's version: a warning plus the manual commands, never a +// failure — the install itself succeeded. +func TestPostInstallRepairs_NoMxKeepsTheHint(t *testing.T) { + calls := stubPostInstall(t, errors.New("not cached"), nil) + var out bytes.Buffer + runPostInstallRepairs(&out, &out, "/p/app.mpr", "11.15.0", moduleRepairs) + if len(*calls) != 0 { + t.Fatalf("no tool may run without mx, ran %v", *calls) + } + s := out.String() + for _, want := range []string{"Warning", "mxcli setup mxbuild", "mxcli fix widgets -p", "mxcli fix design-properties -p"} { + if !strings.Contains(s, want) { + t.Errorf("output missing %q:\n%s", want, s) + } + } +} + +// Unknown Mendix version (the project could not be read): same fallback. +func TestPostInstallRepairs_UnknownVersionKeepsTheHint(t *testing.T) { + calls := stubPostInstall(t, nil, nil) + var out bytes.Buffer + runPostInstallRepairs(&out, &out, "/p/app.mpr", "", widgetRepairs) + if len(*calls) != 0 { + t.Fatalf("no tool may run for an unknown version, ran %v", *calls) + } + if !strings.Contains(out.String(), "mxcli fix widgets -p") { + t.Errorf("expected the manual hint, got:\n%s", out.String()) + } +} + +// One failing step warns, names its manual command, and the next still runs. +func TestPostInstallRepairs_StepFailureWarnsAndContinues(t *testing.T) { + calls := stubPostInstall(t, nil, map[string]error{"update-widgets": errors.New("boom")}) + var out bytes.Buffer + runPostInstallRepairs(&out, &out, "/p/app.mpr", "11.15.0", moduleRepairs) + if got := strings.Join(*calls, ","); got != "update-widgets,rename-design-properties" { + t.Fatalf("tools run = %q, want both despite the first failing", got) + } + s := out.String() + if !strings.Contains(s, "boom") || !strings.Contains(s, "mxcli fix widgets -p") { + t.Errorf("expected the failure and its manual command, got:\n%s", s) + } + if strings.Contains(s, "mxcli fix design-properties -p") { + t.Errorf("the step that succeeded must not print its manual hint:\n%s", s) + } +} + +// A module installed by the transplant writer runs both repairs instead of +// printing them as a "Next" step. +func TestReportTransplantInstall_RunsRepairs(t *testing.T) { + calls := stubPostInstall(t, nil, nil) + var out bytes.Buffer + reportTransplantInstall(&out, "/p/app.mpr", "11.15.0", "Connector", "version 1.0.0", &mp.UpdateResult{UnitsCopied: 7}) + if got := strings.Join(*calls, ","); got != "update-widgets,rename-design-properties" { + t.Fatalf("tools run = %q, want both repairs\noutput:\n%s", got, out.String()) + } + if !strings.Contains(out.String(), `Installed module "Connector"`) { + t.Errorf("install report missing:\n%s", out.String()) + } +} + +// The --file widget path runs the resync after placing the file. +func TestInstallFile_WidgetRunsUpdateWidgets(t *testing.T) { + calls := stubPostInstall(t, nil, nil) + origVer := postInstallMendixVersion + postInstallMendixVersion = func(string) string { return "11.15.0" } + t.Cleanup(func() { postInstallMendixVersion = origVer }) + + mpr := placeholderProject(t) + src := buildLocalMPK(t, "Badge.mpk", map[string]string{"package.xml": widgetPackageXML}) + out, err := runInstallFile(t, "--file", src, "-p", mpr) + if err != nil { + t.Fatalf("run: %v\noutput: %s", err, out) + } + if got := strings.Join(*calls, ","); got != "update-widgets" { + t.Errorf("tools run = %q, want update-widgets\noutput:\n%s", got, out) + } +} diff --git a/docs-site/src/guides/marketplace.md b/docs-site/src/guides/marketplace.md index db36673d88..11fcc3a390 100644 --- a/docs-site/src/guides/marketplace.md +++ b/docs-site/src/guides/marketplace.md @@ -300,6 +300,10 @@ mxcli fix widgets -p app.mpr # CE0463 mxcli fix design-properties -p app.mpr # CE6087 ``` +`marketplace install` runs both itself after a module install (a widget install runs +the widget resync only) when `mx` for the project's version is available, and falls +back to printing these two commands with a warning when it is not. + ```text Updated design properties: 42 unit(s) changed. Storage: 1868 .mxunit file(s), unchanged from 1868 before (MPR v2 preserved). From e3bb427985b90540790927c8aededf08ad79ca89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:05:20 +0000 Subject: [PATCH 19/25] fix(visitor): hint a keyword used as a name and `returns` after option clauses (mendixlabs/mxcli#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bare parse errors in workflow MDL: 1. `call microflow M.ACT_Close as close` gave "mismatched input 'close' expecting {IDENTIFIER, QUOTED_IDENTIFIER}" with no hint: the keyword hint only matched the fixed 41-word hintedKeywordSet. Now any identifier-shaped offending token whose raw expecting set contains QUOTED_IDENTIFIER (read before simplifyExpecting truncates it) gets reservedKeywordHint — such a token can only be a lexer keyword, and a quoted name is exactly what that slot accepts. QUOTED_IDENTIFIER, not IDENTIFIER, because the statement-start set also lists IDENTIFIER. The branch returns before the fixed-list loop, so no word is hinted twice. 2. `returns` after `exposed as workflow action …` (or folder / url / …) gave "mismatched input 'returns' expecting BEGIN", read as "an exposed microflow cannot return a value". The grammar fixes the return type before the option clauses; the error now says so. It fires only when the parser context is a create microflow / nanoflow / rule header — not inside the body, and not past it — so a stray `returns` in a body is not mis-hinted. No grammar change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...-keyword-name-and-returns-order-hints.json | 1 + ...-hints-keyword-name-returns-order.fail.mdl | 42 +++++++ mdl/visitor/keyword_name_hint_test.go | 53 +++++++++ mdl/visitor/returns_order_hint_test.go | 54 +++++++++ mdl/visitor/visitor.go | 104 +++++++++++++++++- 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-keyword-name-and-returns-order-hints.json create mode 100644 mdl-examples/bug-tests/1533-parse-hints-keyword-name-returns-order.fail.mdl create mode 100644 mdl/visitor/keyword_name_hint_test.go create mode 100644 mdl/visitor/returns_order_hint_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-keyword-name-and-returns-order-hints.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-keyword-name-and-returns-order-hints.json new file mode 100644 index 0000000000..ab89468e59 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-keyword-name-and-returns-order-hints.json @@ -0,0 +1 @@ +{"area": "mdl/visitor", "date": "2026-10-16", "symptom": "`call microflow M.ACT_Close as close` in a workflow gives a bare \"mismatched input 'close' expecting {IDENTIFIER, QUOTED_IDENTIFIER}\"; `returns Boolean as $Ok` after `exposed as workflow action 'A' in 'C'` gives \"mismatched input 'returns' expecting BEGIN\" (or STRING_LITERAL inside the clause), read as 'an exposed microflow cannot return a value'", "cause": "The keyword hint only matched the fixed 41-word hintedKeywordSet, so any other lexer keyword in a name slot got no hint; the routine header grammar fixes `microflowReturnType` before `microflowOptions` and nothing explained the order", "file": "`mdl/visitor/visitor.go` (`keywordInNameSlot`, `inRoutineHeader`, `looksLikeMisplacedReturns`)", "insight": "A keyword-in-name-slot test needs no keyword list: an identifier-shaped offending token with QUOTED_IDENTIFIER in the RAW expecting set (read before simplifyExpecting truncates it) can only be a lexer keyword, and quoting is exactly what that set allows. Key on QUOTED_IDENTIFIER, not IDENTIFIER — the statement-start set also lists IDENTIFIER and `end` there would be told to quote itself. For a header-order hint the parser context is the discriminator, not the message: walk GetParserRuleContext() up to Create{Microflow,Nanoflow,Rule}StatementContext, but stop at MicroflowBodyContext AND treat a create context that already has a body child as past the header — a stray `returns` before END reports from the create context too (the body-control test caught that).", "refs": ["mendixlabs/mxcli#1533"]} diff --git a/mdl-examples/bug-tests/1533-parse-hints-keyword-name-returns-order.fail.mdl b/mdl-examples/bug-tests/1533-parse-hints-keyword-name-returns-order.fail.mdl new file mode 100644 index 0000000000..e80e9c0dd4 --- /dev/null +++ b/mdl-examples/bug-tests/1533-parse-hints-keyword-name-returns-order.fail.mdl @@ -0,0 +1,42 @@ +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1533 - two bare parse errors in workflow MDL +-- ============================================================================ +-- +-- Symptom: +-- 1. An activity named with an MDL keyword (`... as close`) gave only +-- "mismatched input 'close' expecting {IDENTIFIER, QUOTED_IDENTIFIER}". +-- The keyword hint fired for a fixed list of 41 words, not for `close`. +-- 2. `returns` written after `exposed as workflow action …` gave +-- "mismatched input 'returns' expecting BEGIN", which reads as if an +-- exposed microflow could not return a value. +-- +-- After fix (both are still parse errors; the grammar is unchanged): +-- 1. The error carries "'close' is a keyword in MDL. Quote it … \"close\"". +-- 2. The error carries "`returns` must come right after the parameter list, +-- before any exposed / folder / url / export level / concurrency clause". +-- +-- Usage (every statement below must be refused, with the hint): +-- mxcli check mdl-examples/bug-tests/1533-parse-hints-keyword-name-returns-order.fail.mdl +-- ============================================================================ + +create module M1533; +create persistent entity M1533.Item (Name: string(100)); + +create microflow M1533.ACT_Close ($Ctx: M1533.Item) +begin +end; + +-- 1. A keyword as an activity name. `as "close"` is the fix. +create workflow M1533.WF + parameter $WorkflowContext: M1533.Item +begin + call microflow M1533.ACT_Close as close; +end workflow; + +-- 2. `returns` after the exposed clause. Swap the two clauses to fix. +create microflow M1533.ACT_Approve ($Ctx: M1533.Item) +exposed as workflow action 'Approve' in 'Cat' +returns Boolean as $Ok +begin + return true; +end; diff --git a/mdl/visitor/keyword_name_hint_test.go b/mdl/visitor/keyword_name_hint_test.go new file mode 100644 index 0000000000..cf8459acce --- /dev/null +++ b/mdl/visitor/keyword_name_hint_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1533 (1): a workflow activity named with an MDL keyword — +// `call microflow M.ACT_Close as close` — gave a bare "mismatched input 'close' +// expecting {IDENTIFIER, QUOTED_IDENTIFIER}". The keyword hint fired only for a +// fixed list of 41 words, and `close` was not on it. +package visitor + +import ( + "strings" + "testing" +) + +const activityNamedSlot = "create workflow M.W\nbegin\n call microflow M.ACT_Close as %s;\nend workflow;" + +func TestKeywordUsedAsNameIsHinted(t *testing.T) { + _, errs := Build(strings.Replace(activityNamedSlot, "%s", "close", 1)) + got := errsText(errs) + if !strings.Contains(got, "mismatched input 'close'") { + t.Fatalf("expected the parse error on 'close', got:\n%s", got) + } + for _, want := range []string{"'close' is a keyword in MDL", `"close"`} { + if !strings.Contains(got, want) { + t.Errorf("hint does not mention %q:\n%s", want, got) + } + } +} + +// The control that makes the advice honest: the quoted form must parse. +func TestKeywordUsedAsNameQuotedParses(t *testing.T) { + if _, errs := Build(strings.Replace(activityNamedSlot, "%s", `"close"`, 1)); len(errs) != 0 { + t.Fatalf("the hint recommends quoting, but the quoted form does not parse: %v", errs) + } +} + +// A word on the old fixed list must still get the hint, and exactly once — the +// generic path and the list path must not both fire. +func TestKeywordHintNotDoubled(t *testing.T) { + for _, name := range []string{"Title", "close"} { + got := hintFor(t, name) + if n := strings.Count(got, "Run 'mxcli syntax keywords'"); n != 1 { + t.Errorf("%s: keyword hint appears %d times, want 1:\n%s", name, n, got) + } + } +} + +// A token that is not identifier-shaped gets no keyword hint. +func TestKeywordHintNotForPunctuation(t *testing.T) { + got := enhanceErrorMessage("mismatched input ';' expecting {IDENTIFIER, QUOTED_IDENTIFIER}", "") + if strings.Contains(got, "is a keyword in MDL") { + t.Errorf("punctuation was hinted as a keyword:\n%s", got) + } +} diff --git a/mdl/visitor/returns_order_hint_test.go b/mdl/visitor/returns_order_hint_test.go new file mode 100644 index 0000000000..0f2bf1f5bf --- /dev/null +++ b/mdl/visitor/returns_order_hint_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1533 (2): `returns` written after `exposed as workflow +// action …` gave "mismatched input 'returns' expecting BEGIN", which reads as +// if an exposed microflow cannot return a value. It can; the grammar fixes the +// order — the return type comes before every option clause. +package visitor + +import ( + "strings" + "testing" +) + +const returnsAfterOptionsHint = "`returns` must come right after the parameter list" + +func TestReturnsAfterExposedIsHinted(t *testing.T) { + for name, src := range map[string]string{ + "exposed": "create microflow M.ACT_A ($Ctx: M.Item)\nexposed as workflow action 'Approve' in 'Cat'\nreturns Boolean as $Ok\nbegin\n return true;\nend;", + "folder": "create nanoflow M.NF () folder 'Sub' returns Boolean\nbegin\n return true;\nend;", + // The exposed clause cut short: the error lands inside the clause. + "inside": "create microflow M.ACT_A ($Ctx: M.Item)\nexposed as workflow action\nreturns Boolean as $Ok\nbegin\n return true;\nend;", + } { + _, errs := Build(src) + got := errsText(errs) + if !strings.Contains(got, "input 'returns'") { + t.Fatalf("%s: expected the parse error on 'returns', got:\n%s", name, got) + } + if n := strings.Count(got, returnsAfterOptionsHint); n != 1 { + t.Errorf("%s: hint appears %d times, want 1:\n%s", name, n, got) + } + } +} + +// The control: the order the hint recommends parses. +func TestReturnsBeforeExposedParses(t *testing.T) { + src := "create microflow M.ACT_A ($Ctx: M.Item)\nreturns Boolean as $Ok\nexposed as workflow action 'Approve' in 'Cat'\nbegin\n return true;\nend;" + if _, errs := Build(src); len(errs) != 0 { + t.Fatalf("the hinted order does not parse: %v", errs) + } +} + +// A stray `returns` inside a microflow BODY is not a header-order mistake and +// must not get this hint (the body has its own `returns` clauses — a REST call's). +func TestReturnsInBodyNotHintedAsHeaderOrder(t *testing.T) { + src := "create microflow M.F ()\nbegin\n returns Boolean;\n return true;\nend;" + _, errs := Build(src) + got := errsText(errs) + if !strings.Contains(got, "input 'returns'") { + t.Fatalf("expected the parse error on 'returns', got:\n%s", got) + } + if strings.Contains(got, returnsAfterOptionsHint) { + t.Errorf("body error got the header-order hint:\n%s", got) + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index 8cf8fc8e9f..1b6644bab5 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -47,7 +47,8 @@ func (l *errorListener) SyntaxError(rec antlr.Recognizer, sym any, line, column if word, ok := unknownStatementWord(rec, sym, msg); ok { msg = unknownStatementMessage(word) } - enhancedMsg := l.deduplicateHint(enhanceErrorMessage(msg, offending), line) + site := errorSite{routineHeader: inRoutineHeader(rec)} + enhancedMsg := l.deduplicateHint(enhanceErrorMessageAt(msg, offending, site), line) l.errors = append(l.errors, fmt.Errorf("line %d:%d %s", line, column, enhancedMsg)) l.lines[line] = true } @@ -117,6 +118,51 @@ func simplifyExpecting(msg string) string { // to `mxcli syntax`. offendingLine is the source line the error occurred on ("" // when unavailable), used for source-aware hints. func enhanceErrorMessage(msg, offendingLine string) string { + return enhanceErrorMessageAt(msg, offendingLine, errorSite{}) +} + +// errorSite is what the parser knew about where an error happened, beyond the +// message and the source line. The zero value is "nothing known". +type errorSite struct { + // routineHeader: the error is in a create microflow / nanoflow / rule + // statement, before its BEGIN (not inside the body). + routineHeader bool +} + +// inRoutineHeader reports whether the parser was inside the header of a create +// microflow, nanoflow or rule statement — between the name and BEGIN — when it +// reported an error. The body, and the END after it, are excluded: a statement +// in the body has clauses of its own (a REST call's `returns`) that are not +// header-order mistakes. +func inRoutineHeader(rec antlr.Recognizer) bool { + p, ok := rec.(antlr.Parser) + if !ok { + return false + } + for ctx := antlr.Tree(p.GetParserRuleContext()); ctx != nil; ctx = ctx.GetParent() { + switch ctx.(type) { + case *parser.MicroflowBodyContext: + return false + case *parser.CreateMicroflowStatementContext, *parser.CreateNanoflowStatementContext, *parser.CreateRuleStatementContext: + // Past the body (the parser wants END) is not the header either. + for _, c := range ctx.GetChildren() { + if _, isBody := c.(*parser.MicroflowBodyContext); isBody { + return false + } + } + return true + } + } + return false +} + +// enhanceErrorMessageAt is enhanceErrorMessage with what the parser knew about +// the error's site. +func enhanceErrorMessageAt(msg, offendingLine string, site errorSite) string { + // Read before simplifyExpecting, which may truncate IDENTIFIER out of the + // expecting set. + keywordAsName := keywordInNameSlot(msg) + // Collapse ANTLR's oversized `expecting {…}` token dumps first, so every // branch below (and the fall-through) shows the tamer form. msg = simplifyExpecting(msg) @@ -302,6 +348,22 @@ func enhanceErrorMessage(msg, offendingLine string) string { // attribute list — `create entity Mod.Child extends Mod.Parent ( ... )` — but // users often append it after the closing parenthesis, where ANTLR reports a // generic "extraneous/mismatched input 'extends'". + // `returns` after an option clause (exposed / folder / url / export level / + // concurrency) of a microflow, nanoflow or rule header (mendixlabs/mxcli#1533). + // The raw "mismatched input 'returns' expecting BEGIN" reads as if an exposed + // microflow could not return a value; it can, the order is fixed. + if site.routineHeader && looksLikeMisplacedReturns(msg) { + return fmt.Sprintf("%s\n\n `returns` must come right after the parameter list, before any exposed /\n"+ + " folder / url / export level / concurrency clause:\n"+ + " create microflow Mod.ACT_A ($Ctx: Mod.Item)\n"+ + " returns Boolean as $Ok\n"+ + " exposed as workflow action 'Approve' in 'Cat'\n"+ + " begin … end; (correct)\n"+ + " create microflow Mod.ACT_A ($Ctx: Mod.Item)\n"+ + " exposed as workflow action 'Approve' in 'Cat'\n"+ + " returns Boolean as $Ok (wrong — causes parse error)", msg) + } + if looksLikeMisplacedExtends(msg) { return fmt.Sprintf("%s\n\n An EXTENDS (or GENERALIZATION) clause must come BEFORE the attribute\n"+ " parentheses, not after them:\n"+ @@ -333,6 +395,15 @@ func enhanceErrorMessage(msg, offendingLine string) string { " 'it's here' (wrong — causes parse error)", msg) } + // Any lexer keyword written where a name goes (mendixlabs/mxcli#1533). The + // expecting set names IDENTIFIER, so the token was identifier-shaped and still + // not an identifier — it is a keyword, and quoting it is what the grammar asks + // for. This returns before the fixed-list loop below, so a word on that list + // gets one hint, not two. + if keywordAsName != "" { + return fmt.Sprintf("%s\n\n%s", msg, reservedKeywordHint(keywordAsName)) + } + // Check for pattern: mismatched input 'Word' or extraneous input 'Word' for keyword := range hintedKeywordSet { patterns := []string{ @@ -447,6 +518,13 @@ func looksLikeMisplacedExtends(msg string) bool { return strings.Contains(lower, "input 'extends'") || strings.Contains(lower, "input 'generalization'") } +// looksLikeMisplacedReturns detects `returns` reported as the offending token — +// in a routine header that means it follows an option clause. +func looksLikeMisplacedReturns(msg string) bool { + lower := strings.ToLower(msg) + return strings.Contains(lower, "mismatched input 'returns'") || strings.Contains(lower, "extraneous input 'returns'") +} + // looksLikeEnumEquals detects `Value = 'Caption'` inside an enumeration value // list. ANTLR reports the `=` as `mismatched input '=' expecting ')'`; an // attribute default with `=` produces a different ("no viable alternative") @@ -790,6 +868,30 @@ func reservedKeywordHint(keyword string) string { keyword, keyword) } +// keywordInNameRe matches an ANTLR error whose offending token is a bare word. +var keywordInNameRe = regexp.MustCompile(`^(?:mismatched|extraneous) input '([A-Za-z_][A-Za-z0-9_]*)' expecting (.*)$`) + +// keywordInNameSlot returns the offending word when a raw ANTLR message reports +// an identifier-shaped token where the grammar expected a name. Such a token can +// only be a lexer keyword: a plain identifier would have matched. +// +// The slot is recognised by QUOTED_IDENTIFIER in the expecting set, not by +// IDENTIFIER alone: the statement-start set also lists IDENTIFIER (for the +// unknown-statement error), and "quote it" is only honest advice where a quoted +// name is accepted. It returns "" otherwise. +func keywordInNameSlot(msg string) string { + m := keywordInNameRe.FindStringSubmatch(msg) + if m == nil { + return "" + } + for _, tok := range strings.FieldsFunc(m[2], func(r rune) bool { return r == '{' || r == '}' || r == ',' || r == ' ' }) { + if tok == "QUOTED_IDENTIFIER" { + return m[1] + } + } + return "" +} + // hintedKeywordSet holds MDL parser keywords people reach for as identifiers. Being on this list says // nothing about whether the name is USABLE — see the hint below. var hintedKeywordSet = map[string]bool{ From cc4a2522e74e7d91e0b80f5ddc232e7de343dd6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:07:01 +0000 Subject: [PATCH 20/25] fix(visitor): hint a routine header written without parameter parentheses (mendixlabs/mxcli#1514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create microflow M.F returns Boolean as $b` gave "missing '(' at 'returns'" and "missing ':' at 'Boolean'"; with a bare `$l: List of …` parameter "missing '(' at '$l'" and "missing ')' at 'returns'". ANTLR's recovery inserts the missing `(` and parses `returns Boolean` as a parameter, so every message points past the real mistake. The hint is keyed off the source line: `create [or modify] microflow|nanoflow|rule ` followed by returns / `$` / as / begin instead of `(`. A header with parentheses cannot match. deduplicateHint already keeps the cascade on that line to one explanation. The follow-on error on the body line is left alone. No grammar change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...16-routine-header-without-parens-hint.json | 1 + ...514-routine-header-without-parens.fail.mdl | 31 +++++++++++ mdl/visitor/routine_parens_hint_test.go | 52 +++++++++++++++++++ mdl/visitor/visitor.go | 22 ++++++++ 4 files changed, 106 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-routine-header-without-parens-hint.json create mode 100644 mdl-examples/bug-tests/1514-routine-header-without-parens.fail.mdl create mode 100644 mdl/visitor/routine_parens_hint_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-routine-header-without-parens-hint.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-routine-header-without-parens-hint.json new file mode 100644 index 0000000000..6cbd89aef4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-routine-header-without-parens-hint.json @@ -0,0 +1 @@ +{"area": "mdl/visitor", "date": "2026-10-16", "symptom": "`create microflow M.F returns Boolean as $b begin … end;` gives \"missing '(' at 'returns'\", \"missing ':' at 'Boolean'\" and a follow-on \"extraneous input 'return'\"; with `$l: List of M.Item` and no parentheses \"missing '(' at '$l'\" / \"missing ')' at 'returns'\" — nothing says the parameter parentheses are mandatory", "cause": "ANTLR single-token insertion supplies the `(` and then parses `returns Boolean` as a parameter named `returns`, so every error lands on a token after the real mistake; no hint keyed on the header shape", "file": "`mdl/visitor/visitor.go` (`routineHeaderWithoutParensRe`)", "insight": "When recovery INSERTS the missing token, the messages describe the recovered parse, not the input — key the hint off the source line (`create [or modify] microflow|nanoflow|rule ` followed by returns / $ / as / begin instead of `(`), which a correct header cannot match. deduplicateHint already collapses the same hint on one line, so the cascade carries one explanation; the follow-on error on the body's later line is left as is (suppressing it would need cross-error state).", "refs": ["mendixlabs/mxcli#1514"]} diff --git a/mdl-examples/bug-tests/1514-routine-header-without-parens.fail.mdl b/mdl-examples/bug-tests/1514-routine-header-without-parens.fail.mdl new file mode 100644 index 0000000000..f212118a9a --- /dev/null +++ b/mdl-examples/bug-tests/1514-routine-header-without-parens.fail.mdl @@ -0,0 +1,31 @@ +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1514 - a microflow header without parameter +-- parentheses gives errors that point at the wrong tokens +-- ============================================================================ +-- +-- Symptom: +-- "missing '(' at 'returns'", then "missing ':' at 'Boolean'"; with a bare +-- parameter "missing '(' at '$l'" and "missing ')' at 'returns'". None says +-- the parentheses are mandatory. +-- +-- After fix (still a parse error; the grammar is unchanged): the first error +-- on the header line carries "A microflow header needs its parameter list in +-- parentheses, even when it is empty", once per line. +-- +-- Usage (both statements must be refused, with the hint): +-- mxcli check mdl-examples/bug-tests/1514-routine-header-without-parens.fail.mdl +-- ============================================================================ + +create module M1514; +create persistent entity M1514.Item (Name: string(100)); + +create microflow M1514.F returns Boolean as $b +begin + declare $b Boolean = true; + return $b; +end; + +create microflow M1514.G $l: List of M1514.Item returns Boolean as $b +begin + return true; +end; diff --git a/mdl/visitor/routine_parens_hint_test.go b/mdl/visitor/routine_parens_hint_test.go new file mode 100644 index 0000000000..279ed4cc12 --- /dev/null +++ b/mdl/visitor/routine_parens_hint_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1514: a microflow header without its parameter parentheses — +// `create microflow M.F returns Boolean as $b` — gave "missing '(' at +// 'returns'", "missing ':' at 'Boolean'", and with a bare `$l: List of …` +// parameter "missing ')' at 'returns'". None says the parentheses are mandatory. +package visitor + +import ( + "strings" + "testing" +) + +const routineParensHint = "parameter list in parentheses" + +func TestRoutineHeaderWithoutParensIsHinted(t *testing.T) { + for name, src := range map[string]string{ + "no params": "create microflow Tmp.F returns Boolean as $b\nbegin\n declare $b Boolean = true;\n return $b;\nend;", + "bare param": "create microflow Tmp.F $l: List of Tmp.Item returns Boolean as $b\nbegin\n return true;\nend;", + "nanoflow": "create nanoflow Tmp.NF begin\nend;", + "or modify": "create or modify microflow Tmp.F returns Boolean\nbegin\n return true;\nend;", + "rule as var": "create rule Tmp.R returns Boolean as $r\nbegin\n return true;\nend;", + } { + _, errs := Build(src) + got := errsText(errs) + if len(errs) == 0 { + t.Fatalf("%s: expected a parse error", name) + } + // One mistake, one explanation — the cascade on the header line must not + // repeat it. + if n := strings.Count(got, routineParensHint); n != 1 { + t.Errorf("%s: hint appears %d times, want 1:\n%s", name, n, got) + } + } +} + +// The control: the form the hint recommends parses, and a correct header with a +// different error on its line is not told to add parentheses. +func TestRoutineHeaderWithParensNotHinted(t *testing.T) { + ok := "create microflow Tmp.F () returns Boolean as $b\nbegin\n declare $b Boolean = true;\n return $b;\nend;" + if _, errs := Build(ok); len(errs) != 0 { + t.Fatalf("the hinted form does not parse: %v", errs) + } + bad := "create microflow Tmp.F ($l List of Tmp.Item) returns Boolean\nbegin\n return true;\nend;" + _, errs := Build(bad) + if len(errs) == 0 { + t.Fatal("expected a parse error") + } + if got := errsText(errs); strings.Contains(got, routineParensHint) { + t.Errorf("a header with parentheses got the parentheses hint:\n%s", got) + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index 1b6644bab5..fc1cd34beb 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -175,6 +175,21 @@ func enhanceErrorMessageAt(msg, offendingLine string, site errorSite) string { " mdl 1;\n"+ " create entity Shop.Customer ( Name: String(200) ); (correct)", msg) } + // A microflow / nanoflow / rule header without its parameter parentheses + // (mendixlabs/mxcli#1514). ANTLR's recovery inserts the missing `(` and then + // reads `returns Boolean` or `$l: List of …` as a parameter, so the errors + // point at `returns`, `Boolean` and `)` — never at the parentheses. Keyed off + // the source line; deduplicateHint keeps the cascade on that line to one + // explanation. + if m := routineHeaderWithoutParensRe.FindStringSubmatch(offendingLine); m != nil { + kind := strings.ToLower(m[1]) + return fmt.Sprintf("%s\n\n A %s header needs its parameter list in parentheses, even when it is\n"+ + " empty — they are not optional:\n"+ + " create %s Mod.F () returns Boolean as $b (correct)\n"+ + " create %s Mod.F ($l: List of Mod.Item) returns Boolean (correct)\n"+ + " create %s Mod.F returns Boolean as $b (wrong — causes parse error)", + msg, kind, kind, kind, kind) + } // `drop microflow M.F if exists;` — the SQL order. The grammar takes the // clause before the name, and ANTLR reports only an extraneous `if`. if dropIfExistsAfterNameRe.MatchString(offendingLine) { @@ -452,6 +467,13 @@ var dropIfExistsAfterNameRe = regexp.MustCompile(`(?i)^\s*drop\s+[a-z][a-z ]*?\s // `Icon: 'Mod.Coll.add-circle'`. Group 1 is the collection, group 2 the name. var hyphenatedIconRe = regexp.MustCompile(`(?i)\bicon\s*:?\s*'?([A-Za-z_]\w*\.[A-Za-z_]\w*)\.([A-Za-z_]\w*(?:-\w+)+)'?`) +// routineHeaderWithoutParensRe matches `create [or modify] microflow|nanoflow|rule +// ` followed directly by what comes after the parameter list — `returns`, +// a `$param`, `as` or `begin` — instead of `(`. A header with its parentheses +// cannot match, so an unrelated error on a correct header line is not hinted. +var routineHeaderWithoutParensRe = regexp.MustCompile( + `(?i)^\s*create\s+(?:or\s+\w+\s+)?(microflow|nanoflow|rule)\s+(?:if\s+not\s+exists\s+)?[\w."]+\s+(?:returns\b|\$|as\b|begin\b)`) + var workflowAccessRe = regexp.MustCompile(`(?i)^\s*(grant|revoke)\s+execute\s+on\s+workflow\b`) // addMissingAttributeRe matches `add :` on a source line — the shape of an From cd4c52e4e1ea24300ed9842d4e93da91dbd59252 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:10:12 +0000 Subject: [PATCH 21/25] fix(visitor): make the backslash-quote hint version-correct under mdl 0 The #732 hint fired on any parse error on a line containing `\'` and always said "Under mdl 1 a backslash is an ordinary character, so `\'` ends the string literal". In a headerless (mdl 0) script the backslash escapes the apostrophe and the literal does not end there, so the advice pointed the wrong way. build() now reads the escape rule the script was lexed with (parser.HasStrictEscapes on the char stream from newScriptStream) and hands it to the error listener; under mdl 0 the hint explains that `\'` does not end the literal, that a trailing backslash must be doubled, and that a doubled apostrophe means the same under every version. The mdl 1 text is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...26-10-16-backslash-hint-version-blind.json | 1 + mdl/visitor/string_escapes_test.go | 19 ++++++++++++ mdl/visitor/visitor.go | 31 +++++++++++++++---- 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-backslash-hint-version-blind.json diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-backslash-hint-version-blind.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-backslash-hint-version-blind.json new file mode 100644 index 0000000000..1d93afcb5e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-16-backslash-hint-version-blind.json @@ -0,0 +1 @@ +{"area": "mdl/visitor", "date": "2026-10-16", "symptom": "In a headerless (mdl 0) script, any parse error on a line containing `\\'` carries \"Under mdl 1 a backslash is an ordinary character, so `\\'` ends the string literal\" — but under mdl 0 the backslash escapes the apostrophe and the literal does NOT end there, so the advice explains a failure that did not happen", "cause": "The #732 backslash hint keys only on the source line containing `\\'`; enhanceErrorMessage never knew which escape rule the script was lexed with", "file": "`mdl/visitor/visitor.go` (`errorListener.backslashEscapes`, `errorSite.backslashEscapes`, the `\\'` branch of `enhanceErrorMessageAt`)", "insight": "A hint that explains a version-dependent rule must be told the version: the escape rule is decided once per script in newScriptStream, so read it back with parser.HasStrictEscapes(chars) in build() and hand it to the listener — do not re-scan the header. Found as a side finding while fixing #1533/#1514; the mdl 1 test never caught it because it only ever ran under `mdl 1;`.", "refs": ["mendixlabs/mxcli#732"]} diff --git a/mdl/visitor/string_escapes_test.go b/mdl/visitor/string_escapes_test.go index 5783a9f341..18c09bc0f5 100644 --- a/mdl/visitor/string_escapes_test.go +++ b/mdl/visitor/string_escapes_test.go @@ -145,3 +145,22 @@ func TestBackslashQuoteHintUnderMdl1(t *testing.T) { t.Fatalf("want the doubled-apostrophe hint, got %v", errs) } } + +// Under mdl 0 (no header) a backslash escapes, so `\'` does NOT end the +// literal. The hint used to say it did — the mdl 1 explanation printed for a +// script that is not mdl 1. +func TestBackslashQuoteHintUnderMdl0(t *testing.T) { + _, errs := Build(`create persistent entity M.Note (Text: String(200) default 'it\'s' x);`) + got := errsText(errs) + if len(errs) == 0 { + t.Fatal("expected a parse error") + } + if strings.Contains(got, "ends the string") { + t.Errorf("mdl 0 script told that `\\'` ends the literal:\n%s", got) + } + for _, want := range []string{"mdl 0", "does not end it", "'it''s'"} { + if !strings.Contains(got, want) { + t.Errorf("hint does not mention %q:\n%s", want, got) + } + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index fc1cd34beb..bc82286f63 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -27,6 +27,9 @@ type errorListener struct { // lines holds every line a syntax error was reported on, so a builder // check does not add a second, recovery-made error on the same line. lines map[int]bool + // backslashEscapes: the script is read under mdl 0, where a backslash in a + // string literal escapes the next character (no StrictEscapeStream). + backslashEscapes bool } func newErrorListener() *errorListener { @@ -47,7 +50,7 @@ func (l *errorListener) SyntaxError(rec antlr.Recognizer, sym any, line, column if word, ok := unknownStatementWord(rec, sym, msg); ok { msg = unknownStatementMessage(word) } - site := errorSite{routineHeader: inRoutineHeader(rec)} + site := errorSite{routineHeader: inRoutineHeader(rec), backslashEscapes: l.backslashEscapes} enhancedMsg := l.deduplicateHint(enhanceErrorMessageAt(msg, offending, site), line) l.errors = append(l.errors, fmt.Errorf("line %d:%d %s", line, column, enhancedMsg)) l.lines[line] = true @@ -127,6 +130,9 @@ type errorSite struct { // routineHeader: the error is in a create microflow / nanoflow / rule // statement, before its BEGIN (not inside the body). routineHeader bool + // backslashEscapes: the script is mdl 0, where `\'` escapes an apostrophe. + // The zero value is mdl 1's rule, where a backslash is an ordinary character. + backslashEscapes bool } // inRoutineHeader reports whether the parser was inside the header of a create @@ -245,10 +251,21 @@ func enhanceErrorMessageAt(msg, offendingLine string, site errorSite) string { " when (empty) then …\n"+ " end case;", msg) } - // A `\'` in a string literal under mdl 1, where a backslash no longer - // escapes: the literal ends at the quote, and the rest of the line is read - // as MDL. Under mdl 0 the same text parses, so the hint only fires on an - // error. (#732) + // A `\'` in a string literal. Under mdl 1 a backslash no longer escapes: + // the literal ends at the quote, and the rest of the line is read as MDL. + // Under mdl 0 it escapes, so the literal does NOT end there — and the advice + // has to say that instead, or it points the wrong way. Either way the + // doubled apostrophe is the spelling that means the same under every + // version. (#732) + if strings.Contains(offendingLine, `\'`) && site.backslashEscapes { + return fmt.Sprintf("%s\n\n This script has no `mdl 1;` header, so it is read as mdl 0, where a backslash\n"+ + " escapes the next character: `\\'` is an apostrophe inside the literal and\n"+ + " does not end it. A literal meant to end in a backslash ('C:\\') therefore\n"+ + " runs on — write the backslash doubled ('C:\\\\'). For an apostrophe, double\n"+ + " it instead, which means the same under every language version:\n"+ + " default 'it''s' (correct)\n"+ + " default 'it\\'s' (mdl 0 only — under mdl 1 the `\\'` ends the literal)", msg) + } if strings.Contains(offendingLine, `\'`) { return fmt.Sprintf("%s\n\n Under mdl 1 a backslash is an ordinary character, so `\\'` ends the string\n"+ " literal. Double the apostrophe instead, as in a Mendix expression:\n"+ @@ -800,7 +817,9 @@ func build(input string, opts buildOptions, listen func(*Builder) antlr.ParseTre errListener.source = strings.Split(input, "\n") // Create lexer with custom error listener - lexer := parser.NewMDLLexer(newScriptStream(input, opts.implicit)) + chars := newScriptStream(input, opts.implicit) + errListener.backslashEscapes = !parser.HasStrictEscapes(chars) + lexer := parser.NewMDLLexer(chars) lexer.RemoveErrorListeners() lexer.AddErrorListener(errListener) From a540ed207fe0a928c5862a46fc4286095b53ee82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:24:33 +0000 Subject: [PATCH 22/25] fix(executor): note when an unstated localized keeps a stored not localized (mendixlabs/mxcli#1521) create or modify entity and alter entity ... modify attribute keep a stored DateTime LocalizeDate when the statement does not state localized / not localized (#743, #1373). That rule stays, but it was invisible: a script that meant Studio Pro's default left a not localized DateTime in UTC with no output saying so. carryStoredLocalizeDate now reports whether it kept a stored false. The create-or-modify path derives the kept attributes from the written result (an unstated DateTime written not localized can only be the carry), and both paths print one Note line per attribute naming it and the `DateTime localized` spelling that changes it. Nothing is printed when the clause is stated or the stored value is localized. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...ocalized-keeps-not-localized-silently.json | 1 + .claude/skills/mendix/mdl-entities/SKILL.md | 20 ++++---- docs-site/src/language/constraints.md | 4 +- .../1521-datetime-localized-carry-notice.mdl | 32 +++++++++++++ mdl/executor/cmd_entities.go | 47 +++++++++++++++++-- mdl/executor/datetime_not_localized_test.go | 39 +++++++++++++++ 6 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-unstated-localized-keeps-not-localized-silently.json create mode 100644 mdl-examples/bug-tests/1521-datetime-localized-carry-notice.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-unstated-localized-keeps-not-localized-silently.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-unstated-localized-keeps-not-localized-silently.json new file mode 100644 index 0000000000..351bbe10e4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-unstated-localized-keeps-not-localized-silently.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`create or modify persistent entity M.E (Due: DateTime)` and `alter entity M.E modify attribute Due: DateTime` left a stored `not localized` DateTime not localized with no output saying so; the reporter's script meant Studio Pro's default (localized) and a client compared a UTC wall clock to local now", "cause": "deliberate carry (#743, #1373): an unstated `localized` keeps the stored LocalizeDate (carryStoredAttributeState / carryStoredLocalizeDate, and the MODIFY ATTRIBUTE site), but nothing reported that the carry had decided the value", "file": "mdl/executor/cmd_entities.go (carryStoredLocalizeDate now returns whether it kept false; keptNotLocalized, noteKeptNotLocalized; called after the create-or-modify write and after MODIFY ATTRIBUTE)", "insight": "A preservation rule the statement cannot show needs a line of output when it overrides what the statement appears to say. Kept the rule (maintainer decision) and noted only the surprising direction: unstated + stored false. For create or modify the note is derived from the written result (an unstated DateTime written false can only be the carry, since a new one defaults to true), so it cannot drift from the carry logic. The OData external-entity rewrite shares carryStoredAttributeState but was left without the note", "refs": ["mendixlabs/mxcli#1521"]} diff --git a/.claude/skills/mendix/mdl-entities/SKILL.md b/.claude/skills/mendix/mdl-entities/SKILL.md index 71d21fc085..3e61872451 100644 --- a/.claude/skills/mendix/mdl-entities/SKILL.md +++ b/.claude/skills/mendix/mdl-entities/SKILL.md @@ -14,15 +14,15 @@ description: "Complete syntax reference for entities, attributes and association | 40-77 | Persistent Entity | | 78-94 | Non-Persistent Entity | | 95-109 | Attribute Types | -| 110-127 | Attribute Modifiers | -| 128-173 | Generalization (Inheritance) | -| 174-222 | Associations | -| 223-239 | Enumerations | -| 240-281 | View Entity (OQL) | -| 282-305 | Entity with Index | -| 306-367 | Complete Domain Model Example | -| 368-385 | Changing an Existing Domain Model | -| 386-428 | Quick Reference | +| 110-129 | Attribute Modifiers | +| 130-175 | Generalization (Inheritance) | +| 176-224 | Associations | +| 225-241 | Enumerations | +| 242-283 | View Entity (OQL) | +| 284-307 | Entity with Index | +| 308-369 | Complete Domain Model Example | +| 370-387 | Changing an Existing Domain Model | +| 388-430 | Quick Reference | # MDL Entity Syntax Reference @@ -120,6 +120,8 @@ create non-persistent entity Module.CustomerSearchParams ( **DateTime localization on a rewrite:** `create or modify` / `modify attribute` that does not state `localized` / `not localized` keeps what is stored (so restating a Studio Pro-authored non-localized date does not localize it); a stated clause wins. +When that keeps a stored `not localized`, exec prints a `Note:` naming the attribute — +write `D: DateTime localized` to switch it on. `describe` prints `not localized` only when it applies. Using either clause on a non-DateTime attribute is a `check` error. diff --git a/docs-site/src/language/constraints.md b/docs-site/src/language/constraints.md index 02e2abfa13..658eb991a0 100644 --- a/docs-site/src/language/constraints.md +++ b/docs-site/src/language/constraints.md @@ -90,7 +90,9 @@ BirthDate: DateTime NOT LOCALIZED, -- not localized - On a **new** attribute, no clause means localized. - On a **rewrite** — `CREATE OR MODIFY ENTITY` or `MODIFY ATTRIBUTE` — no clause keeps the value already stored (a Studio Pro-authored non-localized attribute - stays non-localized); a stated clause wins. + stays non-localized); a stated clause wins. When the keep is a stored + `not localized`, the statement prints a `Note:` naming the attribute and the + `DateTime localized` spelling that changes it. - On a **view entity** the value follows the OQL source attribute and needs no clause; a stated one overrides it, and a mismatch with the source is CE6770. - On any type other than DateTime the clause is an error: there is nowhere to diff --git a/mdl-examples/bug-tests/1521-datetime-localized-carry-notice.mdl b/mdl-examples/bug-tests/1521-datetime-localized-carry-notice.mdl new file mode 100644 index 0000000000..91952679b6 --- /dev/null +++ b/mdl-examples/bug-tests/1521-datetime-localized-carry-notice.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1521: an unstated `localized` keeps a stored `not localized` +-- ============================================================================ +-- +-- A rewrite that says nothing about localization keeps what is stored (#743, +-- #1373), so the two middle statements leave Due not localized. That is the +-- rule, kept; what changed is that each now prints +-- +-- Note: F1521.Item.Due keeps its stored `not localized` (the statement does +-- not say); write `Due: DateTime localized` to change it. +-- +-- The last statement states it and flips the attribute, with no note. +-- +-- Verify: `describe entity F1521.Item` prints `Due: DateTime` (no +-- ` not localized`) at the end; in Studio Pro, Due has "Localize" ticked. +-- ============================================================================ + +create module F1521; + +create persistent entity F1521.Item (Due: DateTime not localized); + +-- Note printed; Due stays not localized. +create or modify persistent entity F1521.Item (Due: DateTime); + +-- Note printed; Due stays not localized. +alter entity F1521.Item modify attribute Due: DateTime; + +-- Stated: Due becomes localized, no note. +create or modify persistent entity F1521.Item (Due: DateTime localized); + +describe entity F1521.Item; diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 5607b407f5..5780476348 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -458,6 +458,7 @@ func execCreateEntity(ctx *ExecContext, s *ast.CreateEntityStmt) error { invalidateHierarchy(ctx) invalidateDomainModelsCache(ctx) ctx.ReportMutation("Modified", "entity: %s", s.Name) + noteKeptNotLocalized(ctx, s.Name.String(), keptNotLocalized(entity.Attributes, statedLocalize(s.Attributes))) resyncViewsOverSource(ctx, s.Name.String(), changedAttributes(storedSignatures, entity)) } else { // Create new entity @@ -577,14 +578,48 @@ func carryStoredAttributeState(stored *domainmodel.Entity, declared []*domainmod // carryStoredLocalizeDate copies a stored DateTime's LocalizeDate onto a rebuilt // type that is still a DateTime. A type change is the statement's to decide, so -// nothing carries onto (or from) another type. -func carryStoredLocalizeDate(stored, rebuilt domainmodel.AttributeType) { +// nothing carries onto (or from) another type. It reports whether that kept a +// stored `not localized` — the case noteKeptNotLocalized tells the user about. +func carryStoredLocalizeDate(stored, rebuilt domainmodel.AttributeType) bool { dt, ok := rebuilt.(*domainmodel.DateTimeAttributeType) if !ok { - return + return false } if odt, ok := stored.(*domainmodel.DateTimeAttributeType); ok { dt.LocalizeDate = odt.LocalizeDate + return !odt.LocalizeDate + } + return false +} + +// keptNotLocalized names the attributes a rewrite left `not localized` without +// the statement saying so: an unstated `localized` on a DateTime defaults to +// localized for a new attribute, so a written false on an unstated one can only +// be the carried stored value. +func keptNotLocalized(attrs []*domainmodel.Attribute, stated map[string]bool) []string { + var names []string + for _, a := range attrs { + if a == nil || stated[a.Name] { + continue + } + if dt, ok := a.Type.(*domainmodel.DateTimeAttributeType); ok && !dt.LocalizeDate { + names = append(names, a.Name) + } + } + return names +} + +// noteKeptNotLocalized says when an unstated `localized` kept a stored `not +// localized` (mendixlabs/mxcli#1521). The carry is deliberate — a rewrite must +// not flip a Studio Pro choice it has no words for (#743) — but it is invisible +// in the statement, and the reporter's script that meant the default left a +// DateTime in UTC with no trace. Stated values and stored-localized attributes +// are never noted. +func noteKeptNotLocalized(ctx *ExecContext, entity string, names []string) { + for _, n := range names { + fmt.Fprintf(ctx.Output, + " Note: %s.%s keeps its stored `not localized` (the statement does not say); "+ + "write `%s: DateTime localized` to change it.\n", entity, n, n) } } @@ -1443,6 +1478,7 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { return err } found := false + keptUnlocalized := false storedSignatures := attributeSignatures(entity) for _, attr := range entity.Attributes { if attr.Name == s.AttributeName { @@ -1452,7 +1488,7 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { // the statement says `localized` / `not localized` (#1373); // rebuilding the type reset it to true. if s.DataType.Localize == ast.LocalizeUnstated { - carryStoredLocalizeDate(storedType, attr.Type) + keptUnlocalized = carryStoredLocalizeDate(storedType, attr.Type) } if s.Calculated { attrValue, err := resolveCalculatedValue(ctx, s.CalculatedMicroflow, s.Name.String(), s.AttributeName, s.DataType) @@ -1492,6 +1528,9 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { invalidateHierarchy(ctx) invalidateDomainModelsCache(ctx) ctx.ReportMutation("Modified", "attribute '%s' on entity %s", s.AttributeName, s.Name) + if keptUnlocalized { + noteKeptNotLocalized(ctx, s.Name.String(), []string{s.AttributeName}) + } resyncViewsOverSource(ctx, s.Name.String(), changedAttributes(storedSignatures, entity)) case ast.AlterEntityDropAttribute: diff --git a/mdl/executor/datetime_not_localized_test.go b/mdl/executor/datetime_not_localized_test.go index 2abc4a8b8d..e0dd590c11 100644 --- a/mdl/executor/datetime_not_localized_test.go +++ b/mdl/executor/datetime_not_localized_test.go @@ -219,3 +219,42 @@ func TestCreateViewEntity_StatedLocalizeWinsOverDerived(t *testing.T) { } } } + +// An unstated `localized` keeps a stored `not localized` (#743, #1373) — a rule +// the user cannot see from the statement, so a script that meant Studio Pro's +// default silently left the attribute in UTC (mendixlabs/mxcli#1521). The +// carry stays; a note names the attribute and the spelling that changes it. +func TestLocalizeCarry_NotesKeptNotLocalized(t *testing.T) { + const note = "keeps its stored `not localized`" + cases := []struct { + name, src string + wantNote bool + }{ + {"create or modify, unstated, stored not localized", "create or modify persistent entity Shop.Person (BirthDate: DateTime, CreatedAt: DateTime);", true}, + {"alter modify, unstated, stored not localized", "alter entity Shop.Person modify attribute BirthDate: DateTime;", true}, + {"create or modify, stated localized", "create or modify persistent entity Shop.Person (BirthDate: DateTime localized, CreatedAt: DateTime);", false}, + {"create or modify, stated not localized", "create or modify persistent entity Shop.Person (BirthDate: DateTime not localized, CreatedAt: DateTime);", false}, + {"alter modify, stated localized", "alter entity Shop.Person modify attribute BirthDate: DateTime localized;", false}, + {"alter modify, unstated, stored localized", "alter entity Shop.Person modify attribute CreatedAt: DateTime;", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := newNotLocalizedFixture(t) + f.exec(t, c.src) + out := f.out.String() + if got := strings.Contains(out, note); got != c.wantNote { + t.Fatalf("note present = %v, want %v\noutput:\n%s", got, c.wantNote, out) + } + if c.wantNote { + for _, want := range []string{"BirthDate", "DateTime localized"} { + if !strings.Contains(out, want) { + t.Errorf("note missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "CreatedAt") && strings.Contains(out, "CreatedAt keeps") { + t.Errorf("a stored-localized attribute must not be noted:\n%s", out) + } + } + }) + } +} From 00709120d8a35298e1cb60e3132c52f7cdb0439d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:30:57 +0000 Subject: [PATCH 23/25] fix(check): refuse a list retrieved over an association used as an object (mendixlabs/mxcli#1516) `retrieve $L from $h/M.Line_Header` from the TO entity of a Reference (the "one" side) binds a LIST. Using it as an object -- `$L/Qty`, `change $L`, passing it to an object parameter -- passed check --references and exec, then mx check failed with CE0117 / CE7247 (measured identically on 11.6.6 and 11.15.0; the bare list retrieve, count and loop build clean on both). MDL-RETRIEVE03 only seeded database retrieves: list-vs-object over an association needs the association's type, owner and which end the start variable is. The decision now lives in associationRetrieveYieldsList, used by the flow builder and by a new --references pass that tracks each object variable's entity, resolves the start end (script + project associations, generalization chain) and reports member-path, change and object-parameter use of the list under MDL-RETRIEVE03, through the walk the no-project rule now shares. Owner Both (one-to-one) and the FROM side stay objects. MDL043: with a project or a script that creates it, a bare declare type that is an enumeration is not an object declare -- exec writes it as the enumeration variable and it builds (11.15.0). It now warns with describe's `enum` spelling instead of refusing it with the object wording. Docs: microflow.variables documents the conditional-create idiom (one create with conditional values, or a sub-microflow returning per branch; the same name created in both branches is CE0111 on 11.15.0) and the enum declare; microflow.retrieve and the error reference describe association list results; MDL063's suggestion names the idiom for a create. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...ociation-retrieve-list-used-as-object.json | 1 + .../mendix/cheatsheet-variables/SKILL.md | 30 +- cmd/mxcli/syntax/features_microflow.go | 33 +- docs-site/src/appendixes/error-messages.md | 14 + .../1516-retrieve-list-over-association.mdl | 55 ++++ .../cmd_microflows_builder_actions.go | 7 +- mdl/executor/validate.go | 5 + mdl/executor/validate_datasource_args.go | 11 + mdl/executor/validate_declare_enum.go | 97 ++++++ mdl/executor/validate_declare_enum_test.go | 129 ++++++++ mdl/executor/validate_microflow.go | 30 +- mdl/executor/validate_microflow_ce_gaps.go | 18 +- .../validate_microflow_retrieve_single.go | 88 ++++-- .../validate_retrieve_association_list.go | 289 ++++++++++++++++++ ...validate_retrieve_association_list_test.go | 176 +++++++++++ mdl/executor/validate_void_code_calls.go | 10 + 16 files changed, 950 insertions(+), 43 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-association-retrieve-list-used-as-object.json create mode 100644 mdl-examples/bug-tests/1516-retrieve-list-over-association.mdl create mode 100644 mdl/executor/validate_declare_enum.go create mode 100644 mdl/executor/validate_declare_enum_test.go create mode 100644 mdl/executor/validate_retrieve_association_list.go create mode 100644 mdl/executor/validate_retrieve_association_list_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-association-retrieve-list-used-as-object.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-association-retrieve-list-used-as-object.json new file mode 100644 index 0000000000..5223b4e3d8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-association-retrieve-list-used-as-object.json @@ -0,0 +1 @@ +{"date": "2026-10-16", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#1516: `retrieve $L from $h/M.Line_Header` (from the TO entity Header, the \"one\" side) followed by `$L/Qty`, `change $L (…)` or `call microflow M.Sub(line = $L)` passed check --references and exec, then mx check reported CE0117 \"Error(s) in expression.\" / CE7247 \"Variable 'L' is of type List of M.Line, but should be of type Object.\"; MDL043 also told `declare $X M.SomeEnum = empty` it was an object declare even with -p.", "cause": "MDL-RETRIEVE03 (validate_microflow_retrieve_single.go) only seeded database retrieves, because list-vs-object over an association needs the association's type/owner and which end the start variable is; the builder decided it (cmd_microflows_builder_actions.go) but typed an object use leniently. MDL043 ran in the no-project validator only and never asked whether the bare name was an enumeration.", "file": "mdl/executor/validate_retrieve_association_list.go", "fix": "associationRetrieveYieldsList(refSet, ownerBoth, fromToSide), used by the builder and by a new --references pass (validateAssociationRetrieveListUse) that tracks each object variable's entity, resolves the start end via checkAssociationShapes + generalizationChain, and reports member path / change / object-parameter use of the list under MDL-RETRIEVE03 through the walk the no-project rule now shares (walkListObjectUses). MDL043 asks the flow-rule resolver (voidCodeActions.declaredTypeKindOf) and, for a known enumeration, warns with describe's `enum` spelling instead of refusing.", "test": "TestAssociationRetrieveListUsedAsObject, TestAssociationRetrieveYieldsList, TestMDL043_EnumerationNamedBare, TestMDL063_ConditionalCreateNamesTheIdiom; mdl-examples/bug-tests/1516-retrieve-list-over-association.mdl", "insight": "The issue's literal repro (bare list retrieve, unused) builds clean on 11.15.0 as on 11.6.6 — the build failure is only in the USE, so re-measure the reporter's version but expect the defect one statement later than the repro shows. Measured on both: owner Both from the TO side is one object (control), a ReferenceSet a list either way, and `retrieve … where [Assoc = $h] first` the working one-object form. The bare enum declare builds at 0 errors when the project has the enumeration (describe prints `enum`), so MDL043 there was a false error, not just a wording issue. Same name created in both if/else branches is CE0111 on 11.15.0 — Mendix does not allow it; the conditional-create idioms are one create with `if … then … else` values, or a sub-microflow returning per branch."} diff --git a/.claude/skills/mendix/cheatsheet-variables/SKILL.md b/.claude/skills/mendix/cheatsheet-variables/SKILL.md index 7ec33c4705..cda0ec6b2e 100644 --- a/.claude/skills/mendix/cheatsheet-variables/SKILL.md +++ b/.claude/skills/mendix/cheatsheet-variables/SKILL.md @@ -12,10 +12,10 @@ description: "One-page reference for declaring and assigning variables in MDL mi |---|---| | 25-37 | Declaration Syntax | | 38-45 | Key Rules | -| 46-88 | Common Mistakes | -| 89-97 | Special Values | -| 98-117 | Parameter vs Variable | -| 118-131 | Variable Scope | +| 46-110 | Common Mistakes | +| 111-119 | Special Values | +| 120-139 | Parameter vs Variable | +| 140-153 | Variable Scope | # MDL Variable Cheatsheet @@ -59,6 +59,28 @@ retrieve $Product from Module.Product where Code = $c first; -- retrieve -- or accept it as a parameter, or use a loop iterator: loop $Product in $Products ... ``` +An enumeration is a primitive and may be declared — write it `enum`, as `describe` +prints it: `declare $S enum Module.Status = empty;`. A bare `Module.Status` reads as +an object type, so without `-p` check refuses it as MDL043 (with `-p` it warns). + +### Conditional create — no "declare then create in a branch" + +```mdl +-- WRONG: declare + create (MDL043, then MDL063 / CE0111), or the same name +-- created in both branches (CE0111 — names are unique flow-wide, measured 11.15) +if $Big then + $Line = create Module.Line (Qty = 10); +else + $Line = create Module.Line (Qty = 1); +end if; + +-- CORRECT: one create, the condition in the values +$Line = create Module.Line (Qty = if $Big then 10 else 1); +-- CORRECT for different shapes: a sub-microflow that creates and returns the +-- object on each branch; the caller binds it once: +$Line = call microflow Module.SUB_MakeLine(Big = $Big); +``` + ### SET Without DECLARE ```mdl diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 068fe46a0b..cc3153bf53 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -139,8 +139,30 @@ func init() { "-- An OBJECT variable cannot be reassigned (CE7247, MDL-SET01): return the\n" + "-- new object from a sub-microflow, or `change $Obj (…)` its members.\n" + "-- A parameter cannot be reassigned either (CE7247, MDL-SET01) unless it is\n" + - "-- a list: copy it first, `DECLARE $Value Integer = $N;`.", - Example: "DECLARE $Count Integer = 0;\nDECLARE $Name String;\nset $Count = $Count + 1;\nset $Name = 'Hello';\nSET $Order/Status = 'Pending';", + "-- a list: copy it first, `DECLARE $Value Integer = $N;`.\n" + + "-- An enumeration variable: `DECLARE $S enum Module.Enum = empty;` (what\n" + + "-- DESCRIBE prints). A bare `Module.Enum` reads as an object type (MDL043).\n" + + "\n" + + "-- Conditional create: an object variable cannot be DECLAREd (CE0053, MDL043)\n" + + "-- and then created in a branch (CE0111, MDL063), and creating the same\n" + + "-- name in both branches is CE0111 too — variable names are unique\n" + + "-- flow-wide. Either create once with conditional member values, or let a\n" + + "-- sub-microflow create and return the object on each branch (see example).", + Example: "DECLARE $Count Integer = 0;\nDECLARE $Name String;\nset $Count = $Count + 1;\nset $Name = 'Hello';\nSET $Order/Status = 'Pending';\n\n" + + "-- Conditional create, one activity: the condition goes into the values\n" + + "$Line = CREATE MyModule.Line (Qty = if $Big then 10 else 1);\n\n" + + "-- Conditional create, different shapes: a sub-microflow returns per branch\n" + + "CREATE MICROFLOW MyModule.SUB_MakeLine ($Big: Boolean) RETURNS MyModule.Line AS $Line\n" + + "BEGIN\n" + + " IF $Big THEN\n" + + " $BigLine = CREATE MyModule.Line (Qty = 10);\n" + + " RETURN $BigLine;\n" + + " ELSE\n" + + " $SmallLine = CREATE MyModule.Line (Qty = 1);\n" + + " RETURN $SmallLine;\n" + + " END IF;\n" + + "END;\n" + + "-- caller: $Line = CALL MICROFLOW MyModule.SUB_MakeLine(Big = $Big);", SeeAlso: []string{"microflow.object-operations"}, }) @@ -168,6 +190,13 @@ func init() { "-- HEAD() or COUNT() over it is CE0097 at build time and a LOOP CE0100;\n" + "-- mxcli reports both as MDL-RETRIEVE01 at check time. The reverse,\n" + "-- CHANGE over a list retrieve, is CE1613 and MDL-RETRIEVE03.\n" + + "-- * Over an association, the result follows the association: from the\n" + + "-- FROM entity of a Reference ($Order above) one object; from its TO\n" + + "-- entity ($Customer) a LIST, unless the owner is Both; a ReferenceSet a\n" + + "-- list either way. Using such a list as an object ($Orders/Attr,\n" + + "-- CHANGE, an object parameter) is CE0117 / CE7247 — check --references\n" + + "-- reports it as MDL-RETRIEVE03. For one object, retrieve it instead:\n" + + "-- RETRIEVE $O FROM MyModule.Order WHERE [MyModule.Order_Customer = $Customer] FIRST;\n" + "-- * $Obj/System.owner and $Obj/System.changedBy are not associations a\n" + "-- retrieve can follow (CE0136, MDL-RETRIEVE02). Use\n" + "-- RETRIEVE $U FROM System.User WHERE [id = $Obj/System.owner] FIRST;\n" + diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index 12822f64d3..1b20afa0c3 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -373,6 +373,20 @@ retrieve $E from Mod.Ent first; change $E (Attr = 'x'); ``` +**Over an association** (`check --references`, mendixlabs/mxcli#1516). Whether +`retrieve $L from $h/Mod.Assoc` binds a list depends on the association: from the FROM +entity of a Reference it is one object; from its TO entity (the "one" side) it is a +**list**, unless the owner is Both; a ReferenceSet is a list from either end. The project +knows which, so with `-p` / `--references` a member path over that list (CE0117), a +`change` of it (CE7247 "Variable 'L' is of type List of …, but should be of type +Object."), and passing it to a parameter that takes one object (CE0117) are reported too +— measured identically on 11.6.6 and 11.15.0. The bare list retrieve, `count`, a `loop` +and a list parameter build. For one object, retrieve it from the database: + +```mdl +retrieve $L from Mod.Line where [Mod.Line_Header = $h] first; +``` + A flow already stored with such a member describes with a `-- WARNING` on the change line. `create or modify` will not splice over it: without the header it rebuilds the flow (which repairs it), and under `mdl 1;` it refuses with the drop-and-create advice. diff --git a/mdl-examples/bug-tests/1516-retrieve-list-over-association.mdl b/mdl-examples/bug-tests/1516-retrieve-list-over-association.mdl new file mode 100644 index 0000000000..67fefeb733 --- /dev/null +++ b/mdl-examples/bug-tests/1516-retrieve-list-over-association.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- mendixlabs/mxcli#1516: `retrieve $L from $h/Assoc` from the TO entity of a +-- Reference (the "one" side) binds a LIST. Using it as an object — `$L/Qty`, +-- `change $L`, passing it to an object parameter — passed check --references and +-- failed mx check with CE0117 / CE7247 (11.6.6 and 11.15.0 alike). check -p now +-- reports those as MDL-RETRIEVE03. This script is the working spelling: the list +-- used as a list, one object from the FROM side, and one object retrieved from the +-- database with the association as the constraint. +-- Also: a bare enumeration declare type, under -p, warns MDL043 with the `enum` +-- spelling instead of refusing it as an object declare. +-- Expected: check -p passes, exec creates everything, mx check reports 0 errors. +create persistent entity MyFirstModule.Bug1516Header ( Name: String(100) ); +create persistent entity MyFirstModule.Bug1516Line ( Qty: Integer ); +create association MyFirstModule.Bug1516Line_Header + from MyFirstModule.Bug1516Line to MyFirstModule.Bug1516Header; +create enumeration MyFirstModule.Bug1516Enum ( A 'A', B 'B' ); + +-- From the Header: a list. Count it, loop it. +create or modify microflow MyFirstModule.MF_Bug1516_ListFromOneSide ($h: MyFirstModule.Bug1516Header) +returns Integer as $n +begin + retrieve $Lines from $h/MyFirstModule.Bug1516Line_Header; + loop $Line in $Lines begin + change $Line ( Qty = 1 ); + end loop; + $n = count $Lines; + return $n; +end; + +-- One Line for the Header: retrieve it from the database. +create or modify microflow MyFirstModule.MF_Bug1516_OneLine ($h: MyFirstModule.Bug1516Header) +returns Integer as $n +begin + retrieve $Line from MyFirstModule.Bug1516Line where [MyFirstModule.Bug1516Line_Header = $h] first; + declare $n Integer = $Line/Qty; + return $n; +end; + +-- From the Line: one Header. +create or modify microflow MyFirstModule.MF_Bug1516_ObjectFromManySide ($l: MyFirstModule.Bug1516Line) +returns Boolean as $b +begin + retrieve $Header from $l/MyFirstModule.Bug1516Line_Header; + declare $b Boolean = $Header/Name != empty; + return $b; +end; + +-- An enumeration variable, spelled as describe prints it. +create or modify microflow MyFirstModule.MF_Bug1516_EnumDeclare () +returns Boolean as $b +begin + declare $S enum MyFirstModule.Bug1516Enum = empty; + declare $b Boolean = $S = empty; + return $b; +end; diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 693cfdc1ff..d6146efbed 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -1225,7 +1225,12 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { if startsFromChildSide { otherEntity = assocInfo.parentEntityQN } - if startsFromChildSide && !outputUsedAsObject { + // associationRetrieveYieldsList is shared with check + // --references (MDL-RETRIEVE03, mendixlabs/mxcli#1516), which + // refuses the object use this branch types leniently. + yieldsList := associationRetrieveYieldsList(false, + assocInfo.Owner == domainmodel.AssociationOwnerBoth, startsFromChildSide) + if yieldsList && !outputUsedAsObject { fb.varTypes[s.Variable] = "List of " + otherEntity } else { fb.varTypes[s.Variable] = otherEntity diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 5f98e2162b..62f68bc5ba 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -467,6 +467,11 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, for _, msg := range validateMemberReferences(ctx, prog, sc) { errors = append(errors, mdlerrors.NewValidation(msg)) } + // MDL-RETRIEVE03 over an association: whether `retrieve $L from $h/M.A` + // binds a list depends on the association and on which end $h is, so the + // no-project rule cannot judge it. Using that list as an object built as + // CE0117 / CE7247 (mendixlabs/mxcli#1516). + errors = append(errors, validateAssociationRetrieveListUse(ctx, prog, sc)...) // Resolve the MEMBERS inside a widget's XPath constraint. The entity in // `database from Mod.Entity` was resolved and the `where […]` was not, so a // member that does not exist reached mxbuild as CE1613 diff --git a/mdl/executor/validate_datasource_args.go b/mdl/executor/validate_datasource_args.go index a89e329ee6..eb9691cb2d 100644 --- a/mdl/executor/validate_datasource_args.go +++ b/mdl/executor/validate_datasource_args.go @@ -104,6 +104,7 @@ type flowParam struct { Name string Entity string // entity qualified name, "" when unknown or not an object Object bool // takes an object or a list of objects + List bool // takes a list (Object is then true too) } // flowSignature is a flow's parameters plus the entity it returns — the return @@ -602,11 +603,20 @@ func sdkFlowSignature(params []*microflows.MicroflowParameter, ret microflows.Da Name: p.Name, Entity: entity, Object: entity != "", + List: isSDKListType(p.Type), }) } return sig } +func isSDKListType(dt microflows.DataType) bool { + switch dt.(type) { + case *microflows.ListType, microflows.ListType: + return true + } + return false +} + // sdkDataTypeEntity returns the entity qualified name behind an object or list // data type, and "" for everything else. func sdkDataTypeEntity(dt microflows.DataType) string { @@ -637,6 +647,7 @@ func astFlowSignature(params []ast.MicroflowParam, ret *ast.MicroflowReturnType) Name: p.Name, Entity: entity, Object: entity != "", + List: p.Type.Kind == ast.TypeListOf, }) } return sig diff --git a/mdl/executor/validate_declare_enum.go b/mdl/executor/validate_declare_enum.go new file mode 100644 index 0000000000..ea2aedc1bf --- /dev/null +++ b/mdl/executor/validate_declare_enum.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "strings" + +// declaredTypeKind is what a bare `Module.Name` declare type turned out to name. +// +// A bare qualified name parses as TypeEnumeration with EnumRef set — the parser +// cannot tell an entity from an enumeration — so MDL043 used to read every one +// as the object declare it refuses, and worded it for an object even when -p +// was given and the project said the name is an enumeration +// (mendixlabs/mxcli#1516). Measured on 11.15.0: with the enumeration in the +// project, `declare $X Tmp.SomeEnum = empty` is written as an enumeration +// variable (describe prints `declare $X enum Tmp.SomeEnum = empty`) and builds +// at 0 errors — so it is not the object declare at all. +type declaredTypeKind int + +const ( + declKindUnknown declaredTypeKind = iota + declKindEnumeration + declKindEntity + // declKindAmbiguous: an enumeration and an entity share the name. + declKindAmbiguous +) + +func (r *voidCodeActions) recordScriptKind(qn string, k declaredTypeKind) { + if r.scriptKinds == nil { + r.scriptKinds = map[string]declaredTypeKind{} + } + addDeclaredKind(r.scriptKinds, qn, k) +} + +func addDeclaredKind(m map[string]declaredTypeKind, qn string, k declaredTypeKind) { + key := strings.ToLower(qn) + if prev, ok := m[key]; ok && prev != k { + m[key] = declKindAmbiguous + return + } + m[key] = k +} + +// declaredTypeKindOf resolves a declare type's qualified name against the script +// and, when there is one, the project. declKindUnknown when neither knows it — +// the no-project tier always lands there. +func (r *voidCodeActions) declaredTypeKindOf(qn string) declaredTypeKind { + if r == nil || qn == "" { + return declKindUnknown + } + key := strings.ToLower(qn) + script := r.scriptKinds[key] + project := r.projectKindsMap()[key] + switch { + case script == declKindUnknown: + return project + case project == declKindUnknown || project == script: + return script + } + return declKindAmbiguous +} + +func (r *voidCodeActions) projectKindsMap() map[string]declaredTypeKind { + if r.projectKinds != nil { + return r.projectKinds + } + r.projectKinds = map[string]declaredTypeKind{} + b := r.project() + if b == nil { + return r.projectKinds + } + h, err := NewContainerHierarchyFromBackend(b) + if err != nil || h == nil { + return r.projectKinds + } + if enums, err := b.ListEnumerations(); err == nil { + for _, e := range enums { + if e == nil || e.Excluded { + continue + } + if mod := h.GetModuleName(h.FindModuleID(e.ContainerID)); mod != "" { + addDeclaredKind(r.projectKinds, mod+"."+e.Name, declKindEnumeration) + } + } + } + if dms, err := b.ListDomainModels(); err == nil { + for _, dm := range dms { + mod := h.GetModuleName(h.FindModuleID(dm.ContainerID)) + if mod == "" { + continue + } + for _, ent := range dm.Entities { + addDeclaredKind(r.projectKinds, mod+"."+ent.Name, declKindEntity) + } + } + } + return r.projectKinds +} diff --git a/mdl/executor/validate_declare_enum_test.go b/mdl/executor/validate_declare_enum_test.go new file mode 100644 index 0000000000..800f52ed79 --- /dev/null +++ b/mdl/executor/validate_declare_enum_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1516 repro 2: `declare $X Tmp.SomeEnum = empty` got MDL043's +// OBJECT wording even when the project (or the script) says Tmp.SomeEnum is an +// enumeration. Measured on 11.15.0: with the enumeration in the project the bare +// form is written as an enumeration variable (describe prints `enum`) and builds +// at 0 errors. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +const declareEnumFlow = ` +create microflow Tmp.E1 () returns Boolean as $b +begin + declare $X Tmp.SomeEnum = empty; + declare $b Boolean = $X = empty; + return $b; +end;` + +func mdl043(t *testing.T, prog *ast.Program, open func() backend.FullBackend) []linter.Violation { + t.Helper() + voids := newVoidCodeActions(prog, open) + var out []linter.Violation + for _, stmt := range prog.Statements { + if mf, ok := stmt.(*ast.CreateMicroflowStmt); ok { + for _, v := range validateMicroflowWith(mf, voids) { + if v.RuleID == "MDL043" { + out = append(out, v) + } + } + } + } + return out +} + +func declareEnumProject() backend.FullBackend { + modID := model.ID("mod-tmp") + return &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{{BaseElement: model.BaseElement{ID: modID}, Name: "Tmp"}}, nil + }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { + return []*model.Enumeration{{ContainerID: modID, Name: "SomeEnum"}}, nil + }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{{ContainerID: modID, + Entities: []*domainmodel.Entity{{Name: "Item"}}}}, nil + }, + } +} + +func TestMDL043_EnumerationNamedBare(t *testing.T) { + for _, tc := range []struct { + name string + src string + open func() backend.FullBackend + severity linter.Severity + want string + }{ + {"the project says it is an enumeration", declareEnumFlow, declareEnumProject, + linter.SeverityWarning, "declare $X enum Tmp.SomeEnum = empty"}, + {"the script creates the enumeration", "create enumeration Tmp.SomeEnum ( A 'A' );" + declareEnumFlow, nil, + linter.SeverityWarning, "names the enumeration Tmp.SomeEnum"}, + // Controls: the object wording and the error stay where the name is not + // an enumeration, or nothing says what it is. + {"control: no project", declareEnumFlow, nil, + linter.SeverityError, "creates an object variable of type Tmp.SomeEnum"}, + {"control: the project says it is an entity", + strings.Replace(declareEnumFlow, "Tmp.SomeEnum", "Tmp.Item", 1), declareEnumProject, + linter.SeverityError, "creates an object variable of type Tmp.Item"}, + {"control: a name that is both is not guessed", + "create persistent entity Tmp.SomeEnum ( N: Integer );" + declareEnumFlow, declareEnumProject, + linter.SeverityError, "creates an object variable"}, + } { + t.Run(tc.name, func(t *testing.T) { + vs := mdl043(t, parseMDL(t, tc.src), tc.open) + if len(vs) != 1 { + t.Fatalf("want one MDL043, got %d: %+v", len(vs), vs) + } + if vs[0].Severity != tc.severity { + t.Errorf("severity = %v, want %v", vs[0].Severity, tc.severity) + } + if !strings.Contains(vs[0].Message, tc.want) { + t.Errorf("message should contain %q:\n%s", tc.want, vs[0].Message) + } + }) + } +} + +// Repro 3: the conditional create. A create per branch into one name is CE0111 +// (measured 11.15.0), and MDL063 names the two forms that build. +func TestMDL063_ConditionalCreateNamesTheIdiom(t *testing.T) { + prog := parseMDL(t, ` +create microflow Tmp.SameName ($big: Boolean) returns Integer as $n +begin + if $big then + $L = create Tmp.Line (Qty = 10); + else + $L = create Tmp.Line (Qty = 1); + end if; + declare $n Integer = $L/Qty; + return $n; +end;`) + var got []linter.Violation + for _, v := range ValidateMicroflow(prog.Statements[0].(*ast.CreateMicroflowStmt)) { + if v.RuleID == "MDL063" { + got = append(got, v) + } + } + if len(got) != 1 { + t.Fatalf("want one MDL063, got %+v", got) + } + for _, want := range []string{"sub-microflow", "if $c then", "microflow.variables"} { + if !strings.Contains(got[0].Suggestion, want) { + t.Errorf("suggestion should mention %q:\n%s", want, got[0].Suggestion) + } + } +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 7e3852f6e1..21f4e3129c 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -350,13 +350,29 @@ func (v *microflowValidator) walkBody(body []ast.MicroflowStatement) { case stmt.Type.EnumRef != nil: typeName = stmt.Type.EnumRef.String() } - v.addViolation("MDL043", linter.SeverityError, - fmt.Sprintf("declare '$%s' creates an object variable of type %s, but Mendix does not allow the "+ - "Create Variable activity to hold an object (CE0053). "+ - "Get the object from a microflow parameter, a retrieve, or a create object instead — do not declare an object variable. "+ - "(If %s is an enumeration, write it as Enumeration(%s).)", - stmt.Variable, typeName, typeName, typeName), - "Accept the object as a parameter, use retrieve, or use create object — do not declare an object variable") + // With a project (or a script that creates it), the bare name may be + // known to be an ENUMERATION: then this is no object declare, exec + // writes it as the enumeration variable and it builds (measured + // 11.15.0, mendixlabs/mxcli#1516). Say so, as a warning naming + // describe's spelling, instead of refusing it as an object. + if stmt.Type.Kind == ast.TypeEnumeration && + v.voids.declaredTypeKindOf(typeName) == declKindEnumeration { + v.addViolation("MDL043", linter.SeverityWarning, + fmt.Sprintf("declare '$%s' names the enumeration %s bare, which reads as an object type "+ + "wherever the project is not at hand — without -p, check refuses it as an object "+ + "declare (CE0053). Write `declare $%s enum %s = empty`, the spelling describe prints "+ + "(Enumeration(%s) is accepted too).", + stmt.Variable, typeName, stmt.Variable, typeName, typeName), + fmt.Sprintf("Write `declare $%s enum %s = empty;`", stmt.Variable, typeName)) + } else { + v.addViolation("MDL043", linter.SeverityError, + fmt.Sprintf("declare '$%s' creates an object variable of type %s, but Mendix does not allow the "+ + "Create Variable activity to hold an object (CE0053). "+ + "Get the object from a microflow parameter, a retrieve, or a create object instead — do not declare an object variable. "+ + "(If %s is an enumeration, write it as `enum %s`.)", + stmt.Variable, typeName, typeName, typeName), + "Accept the object as a parameter, use retrieve, or use create object — do not declare an object variable") + } } // Register the declared variable's kind for later assignment checks, // and flag a Decimal initial value assigned to an Integer/Long declare. diff --git a/mdl/executor/validate_microflow_ce_gaps.go b/mdl/executor/validate_microflow_ce_gaps.go index d9c31018af..4d078c2b19 100644 --- a/mdl/executor/validate_microflow_ce_gaps.go +++ b/mdl/executor/validate_microflow_ce_gaps.go @@ -251,15 +251,25 @@ func (v *microflowValidator) checkDuplicateVariableNames(params []ast.MicroflowP if prev.loop && p.loop { return // MDL052 } + suggestion := fmt.Sprintf("Rename one of them, or — if you meant to reuse the first — drop the "+ + "redundant definition: %s already creates '$%s', and an activity always creates "+ + "its own output variable rather than writing into an existing one", + prev.label, p.name) + if p.label == "create" { + // The conditional-create idiom: `declare $X M.E = empty` then a create + // per branch, or the same create name in both branches. Both are CE0111 + // (measured 11.15.0, mendixlabs/mxcli#1516); these two forms build. + suggestion += fmt.Sprintf(". For an object created conditionally, create it once with "+ + "conditional values (`$%s = create M.E (Attr = if $c then 1 else 2);`), or let a "+ + "sub-microflow create and return it on each branch — see `mxcli syntax microflow.variables`", + p.name) + } v.addViolation("MDL063", linter.SeverityError, fmt.Sprintf("'$%s' is created twice in this %s — first by %s, then by %s. "+ "A %s's variable names are unique flow-wide (branches and loop bodies do "+ "not open a scope), so mxbuild rejects this with CE0111 \"Duplicate variable name\".", p.name, v.docType, prev.label, p.label, v.docType), - fmt.Sprintf("Rename one of them, or — if you meant to reuse the first — drop the "+ - "redundant definition: %s already creates '$%s', and an activity always creates "+ - "its own output variable rather than writing into an existing one", - prev.label, p.name)) + suggestion) } for _, p := range params { diff --git a/mdl/executor/validate_microflow_retrieve_single.go b/mdl/executor/validate_microflow_retrieve_single.go index b462977e1e..9a95da85d4 100644 --- a/mdl/executor/validate_microflow_retrieve_single.go +++ b/mdl/executor/validate_microflow_retrieve_single.go @@ -74,44 +74,82 @@ const retrieveListAsObjectRule = "MDL-RETRIEVE03" // through the list type, and mxbuild answered CE1613 "The selected attribute // 'List of Module.Entity.Attr' no longer exists" (mendixlabs/mxcli#1422). // -// Only a database retrieve is judged: a retrieve over an association binds an -// object or a list depending on the association, which this AST walk does not -// know. A member path `$E/Attr` in any expression of a later statement is the -// same mistake (CE0117 "Error(s) in expression.", measured on 11.6.6). `set $E -// = empty` over the list is a Change list Replace and builds (measured), so it -// is not flagged. +// Only a database retrieve is judged here: a retrieve over an association binds +// an object or a list depending on the association's type and owner and on +// which end the start variable is, which needs the project. check --references +// judges those with the same walk (validateAssociationRetrieveListUse, +// mendixlabs/mxcli#1516). A member path `$E/Attr` in any expression of a later +// statement is the same mistake (CE0117 "Error(s) in expression.", measured on +// 11.6.6). `set $E = empty` over the list is a Change list Replace and builds +// (measured), so it is not flagged. func (v *microflowValidator) checkRetrieveListAsObject(body []ast.MicroflowStatement) { list := map[string]bool{} + walkListObjectUses(body, func(name string) bool { return list[name] }, + func(_ ast.MicroflowStatement, name string, use listObjectUse) { + switch use { + case listUseChange: + v.addViolation(retrieveListAsObjectRule, linter.SeverityError, + fmt.Sprintf("$%s was retrieved as a list (under `mdl 1;` `limit 1` is a list of one), "+ + "not a single object, so `change $%s` cannot take it — mxbuild rejects the stored member "+ + "with CE1613 \"The selected attribute 'List of …' no longer exists\".", name, name), + fmt.Sprintf("Retrieve the object with `retrieve $%s from … first;`, or keep the list and "+ + "change its items inside a `loop`.", name)) + case listUseMemberPath: + v.addViolation(retrieveListAsObjectRule, linter.SeverityError, + fmt.Sprintf("$%s was retrieved as a list (under `mdl 1;` `limit 1` is a list of one), "+ + "not a single object, so a member path `$%s/…` has nothing to navigate — mxbuild "+ + "rejects the expression with CE0117 \"Error(s) in expression.\".", name, name), + fmt.Sprintf("Retrieve the object with `retrieve $%s from … first;`, or take one item "+ + "of the list first (`$item = head($%s);`).", name, name)) + } + }, + nil, + func(s ast.MicroflowStatement) { + for _, p := range statementProducedVars(s) { + delete(list, p.name) + } + if r, ok := s.(*ast.RetrieveStmt); ok && !r.First && r.StartVariable == "" && r.Variable != "" { + list[r.Variable] = true + } + }) +} + +// listObjectUse is how a statement takes a list variable as one object. +type listObjectUse int + +const ( + listUseChange listObjectUse = iota // `change $L (…)` + listUseMemberPath // `$L/Attr` in one of the statement's expressions +) + +// walkListObjectUses is the walk MDL-RETRIEVE03 runs, with and without a +// project. In statement order (nested bodies included) it reports each `change` +// of, and each member path through, a variable isList says holds a list — at +// most one member-path report per variable per statement. other, when set, runs +// next for checks the caller adds, and rebind last, so a statement that both +// consumes and produces a name is judged on what it consumed. +func walkListObjectUses(body []ast.MicroflowStatement, isList func(string) bool, + use func(ast.MicroflowStatement, string, listObjectUse), + other func(ast.MicroflowStatement), rebind func(ast.MicroflowStatement)) { + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { - if c, ok := s.(*ast.ChangeObjectStmt); ok && list[c.Variable] { - v.addViolation(retrieveListAsObjectRule, linter.SeverityError, - fmt.Sprintf("$%s was retrieved as a list (under `mdl 1;` `limit 1` is a list of one), "+ - "not a single object, so `change $%s` cannot take it — mxbuild rejects the stored member "+ - "with CE1613 \"The selected attribute 'List of …' no longer exists\".", c.Variable, c.Variable), - fmt.Sprintf("Retrieve the object with `retrieve $%s from … first;`, or keep the list and "+ - "change its items inside a `loop`.", c.Variable)) + if c, ok := s.(*ast.ChangeObjectStmt); ok && isList(c.Variable) { + use(s, c.Variable, listUseChange) } reported := map[string]bool{} for _, e := range statementOwnExpressions(s) { for _, name := range predicatePathVariables(e) { - if !list[name] || reported[name] { + if !isList(name) || reported[name] { continue } reported[name] = true - v.addViolation(retrieveListAsObjectRule, linter.SeverityError, - fmt.Sprintf("$%s was retrieved as a list (under `mdl 1;` `limit 1` is a list of one), "+ - "not a single object, so a member path `$%s/…` has nothing to navigate — mxbuild "+ - "rejects the expression with CE0117 \"Error(s) in expression.\".", name, name), - fmt.Sprintf("Retrieve the object with `retrieve $%s from … first;`, or take one item "+ - "of the list first (`$item = head($%s);`).", name, name)) + use(s, name, listUseMemberPath) } } - for _, p := range statementProducedVars(s) { - delete(list, p.name) - } - if r, ok := s.(*ast.RetrieveStmt); ok && !r.First && r.StartVariable == "" && r.Variable != "" { - list[r.Variable] = true + if other != nil { + other(s) } + rebind(s) }) } diff --git a/mdl/executor/validate_retrieve_association_list.go b/mdl/executor/validate_retrieve_association_list.go new file mode 100644 index 0000000000..78718b04ae --- /dev/null +++ b/mdl/executor/validate_retrieve_association_list.go @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// associationRetrieveYieldsList reports whether `retrieve $X from $Start/Assoc` +// binds a LIST rather than one object. It is the one answer both the flow +// builder (which types the output variable from it) and check --references +// (MDL-RETRIEVE03 over an association) use, so a check can never describe a +// retrieve differently from how exec writes it. +// +// - A ReferenceSet yields a list from either end. +// - A Reference followed from its FROM entity (the foreign-key owner, the +// "many" side) yields the one TO object. +// - A Reference followed from its TO entity (the "one" side) yields the list +// of FROM objects pointing at it — unless the owner is Both, which makes it +// one-to-one, and then it is one object from that side too. +// +// The same shapes measured for list widgets (MDL-ASSOCDS01, 11.14.0) and for +// microflow retrieves (mendixlabs/mxcli#1516, 11.6.6 and 11.15.0). +func associationRetrieveYieldsList(refSet, ownerBoth, fromToSide bool) bool { + if refSet { + return true + } + return fromToSide && !ownerBoth +} + +// validateAssociationRetrieveListUse is MDL-RETRIEVE03 for a retrieve over an +// association. The no-project rule judges only database retrieves, because +// whether `retrieve $L from $h/M.A` binds a list or an object depends on the +// association's type, owner and which end $h is — the project knows, the AST +// does not. Without it, using the list as an object passed check --references, +// exec wrote it, and the build failed (mendixlabs/mxcli#1516, measured on +// 11.6.6 and 11.15.0): +// +// $L/Qty in any expression CE0117 "Error(s) in expression." +// change $L (…) CE7247 "Variable 'L' is of type List of …, but should be of type Object." +// call M.Sub(obj = $L) CE0117 (the parameter takes one object) +// +// The bare list retrieve and list uses of it (count, loop, a list parameter) +// build clean on both versions, so they are not reported. +// +// Anything not established is silence: an association neither the project nor +// the script defines, an owner mxcli does not write, a self-association, a start +// variable whose entity this walk did not track, or one that is no end of the +// association by its generalization chain as far as it could be read. +func validateAssociationRetrieveListUse(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { + if prog == nil || !ctx.Connected() { + return nil + } + var shapes map[string]assocShape + var sigs map[string]*flowSignature + signature := func(name string) *flowSignature { + key := strings.ToLower(name) + if sig, ok := sc.flowParams[key]; ok { + return sig + } + if sigs == nil { + sigs = buildFlowSignatures(ctx) + } + return sigs[key] + } + + var errs []error + for i, stmt := range prog.Statements { + var params []ast.MicroflowParam + var body []ast.MicroflowStatement + switch s := stmt.(type) { + case *ast.CreateMicroflowStmt: + params, body = s.Parameters, s.Body + case *ast.CreateNanoflowStmt: + params, body = s.Parameters, s.Body + default: + continue + } + if !hasAssociationRetrieve(body) { + continue + } + if shapes == nil { + shapes = checkAssociationShapes(ctx, sc) + } + msgs := associationListObjectUses(ctx, shapes, signature, params, body) + if len(msgs) == 0 { + continue + } + if kind, name, excluded := excludedFlow(stmt); excluded { + sc.warnExcluded(kind, name, msgs) + continue + } + errs = append(errs, mdlerrors.NewValidationf("statement %d: a retrieve over an association "+ + "that yields a list is used as one object:\n - %s", i+1, strings.Join(msgs, "\n - "))) + } + return errs +} + +func hasAssociationRetrieve(body []ast.MicroflowStatement) bool { + found := false + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + if r, ok := s.(*ast.RetrieveStmt); ok && r.StartVariable != "" { + found = true + } + }) + return found +} + +// assocListBinding is what one association retrieve bound to a list. +type assocListBinding struct { + assoc, start, startEntity, elem string +} + +// associationListObjectUses walks one flow body in statement order, tracking the +// entity each object variable holds (so a retrieve's start end can be decided) +// and which variables an association retrieve bound to a list. +func associationListObjectUses(ctx *ExecContext, shapes map[string]assocShape, + signature func(string) *flowSignature, params []ast.MicroflowParam, body []ast.MicroflowStatement) []string { + + objects := map[string]string{} // object variable -> entity QN + lists := map[string]string{} // list variable -> element entity QN + for _, p := range params { + if e := astDataTypeEntity(p.Type); e != "" { + if p.Type.Kind == ast.TypeListOf { + lists[p.Name] = e + } else { + objects[p.Name] = e + } + } + } + bound := map[string]assocListBinding{} + + var out []string + walkListObjectUses(body, func(name string) bool { _, ok := bound[name]; return ok }, + func(s ast.MicroflowStatement, name string, use listObjectUse) { + b := bound[name] + what := fmt.Sprintf("$%s = retrieve over %s from $%s (a %s) is a list of %s", name, b.assoc, b.start, b.startEntity, b.elem) + var how, rejection string + switch use { + case listUseChange: + how, rejection = fmt.Sprintf("`change $%s` needs one object", name), + fmt.Sprintf("CE7247 \"Variable '%s' is of type List of %s, but should be of type Object.\"", name, b.elem) + case listUseMemberPath: + how, rejection = fmt.Sprintf("a member path `$%s/…` has nothing to navigate", name), + "CE0117 \"Error(s) in expression.\"" + } + out = append(out, fmt.Sprintf("%s, so %s — mxbuild rejects it with %s [MDL-RETRIEVE03]. %s", + what, how, rejection, assocListSuggestion(name, b))) + }, + func(s ast.MicroflowStatement) { + // Object arguments: a list passed to a parameter that takes one object. + var callee string + var args []ast.CallArgument + switch c := s.(type) { + case *ast.CallMicroflowStmt: + callee, args = c.MicroflowName.String(), c.Arguments + case *ast.CallNanoflowStmt: + callee, args = c.NanoflowName.String(), c.Arguments + } + if callee == "" || !strings.Contains(callee, ".") { + return + } + for _, a := range args { + v, ok := a.Value.(*ast.VariableExpr) + if !ok { + continue + } + b, ok := bound[v.Name] + if !ok { + continue + } + sig := signature(callee) + if sig == nil { + return + } + for _, p := range sig.Params { + if p.Name == a.Name && p.Object && !p.List { + out = append(out, fmt.Sprintf("$%s = retrieve over %s from $%s (a %s) is a list of %s, "+ + "so it cannot be passed to parameter %s of %s, which takes one %s — mxbuild rejects it "+ + "with CE0117 \"Error(s) in expression.\" [MDL-RETRIEVE03]. %s", + v.Name, b.assoc, b.start, b.startEntity, b.elem, p.Name, callee, p.Entity, + assocListSuggestion(v.Name, b))) + } + } + } + }, + func(s ast.MicroflowStatement) { + // Rebinding, after the statement's uses were judged. + for _, p := range statementProducedVars(s) { + delete(bound, p.name) + delete(objects, p.name) + delete(lists, p.name) + } + switch st := s.(type) { + case *ast.CreateObjectStmt: + if st.Variable != "" && st.EntityType.Module != "" { + objects[st.Variable] = st.EntityType.String() + } + case *ast.DeclareStmt: + if e := astDataTypeEntity(st.Type); e != "" { + if st.Type.Kind == ast.TypeListOf { + lists[st.Variable] = e + } else { + objects[st.Variable] = e + } + } + case *ast.LoopStmt: + if e, ok := lists[st.ListVariable]; ok && st.LoopVariable != "" { + objects[st.LoopVariable] = e + } + case *ast.RetrieveStmt: + if st.Variable == "" || st.Source.Name == "" { + return + } + if st.StartVariable == "" { + if st.First { + objects[st.Variable] = st.Source.String() + } else { + lists[st.Variable] = st.Source.String() + } + return + } + start := strings.TrimPrefix(st.StartVariable, "$") + startEntity, ok := objects[start] + if !ok { + return + } + other, isList, known := associationRetrieveShape(ctx, shapes, st.Source, startEntity) + if !known { + return + } + if isList { + lists[st.Variable] = other + bound[st.Variable] = assocListBinding{ + assoc: shapes[strings.ToLower(qualifyAssoc(st.Source, startEntity))].qn, + start: start, startEntity: startEntity, elem: other, + } + } else { + objects[st.Variable] = other + } + } + }) + return out +} + +func qualifyAssoc(assoc ast.QualifiedName, startEntity string) string { + if assoc.Module != "" { + return assoc.String() + } + mod, _, _ := strings.Cut(startEntity, ".") + return mod + "." + assoc.Name +} + +// associationRetrieveShape decides what a retrieve over assoc from an object of +// startEntity binds: the entity at the other end, and whether it is a list. +// known is false whenever that was not established. +func associationRetrieveShape(ctx *ExecContext, shapes map[string]assocShape, + assoc ast.QualifiedName, startEntity string) (other string, isList, known bool) { + + shape, ok := shapes[strings.ToLower(qualifyAssoc(assoc, startEntity))] + if !ok || shape.owner == "" || strings.EqualFold(shape.from, shape.to) { + return "", false, false + } + // The start may be a specialization of an end; walk as far as the + // generalization chain can be read, and stay silent if no link is an end. + chain, _ := generalizationChain(ctx, startEntity) + if len(chain) == 0 { + chain = []string{startEntity} + } + for _, qn := range chain { + switch { + case strings.EqualFold(qn, shape.from): + return shape.to, associationRetrieveYieldsList(shape.refSet, shape.owner == "Both", false), true + case strings.EqualFold(qn, shape.to): + return shape.from, associationRetrieveYieldsList(shape.refSet, shape.owner == "Both", true), true + } + } + return "", false, false +} + +func assocListSuggestion(name string, b assocListBinding) string { + return fmt.Sprintf("Keep the list and work on its items (`loop $item in $%s`, or `$item = head($%s);`), "+ + "or retrieve one %s from the database: `retrieve $%s from %s where [%s = $%s] first;`.", + name, name, b.elem, name, b.elem, b.assoc, b.start) +} diff --git a/mdl/executor/validate_retrieve_association_list_test.go b/mdl/executor/validate_retrieve_association_list_test.go new file mode 100644 index 0000000000..4f84efb31a --- /dev/null +++ b/mdl/executor/validate_retrieve_association_list_test.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1516: a retrieve over a Reference from its TO entity (the +// "one" side) binds a LIST, and using it as an object passed check --references +// and failed the build. Measured on 11.6.6 and 11.15.0 alike, with +// Tmp.Line_Header from Tmp.Line to Tmp.Header: +// +// retrieve $L from $h/Tmp.Line_Header; clean (an unused list) +// … $n = count($L); clean +// … declare $n Integer = $L/Qty; CE0117 "Error(s) in expression." +// … change $L (Qty = 5); CE7247 "Variable 'L' is of type List of Tmp.Line, but should be of type Object." +// … call microflow Tmp.Sub(line = $L); CE0117 (Sub takes one Tmp.Line) +// retrieve $H from $l/Tmp.Line_Header; $H/Name clean (the FROM side is one object) +package executor + +import ( + "strings" + "testing" +) + +const assocListSetup = ` +create module Tmp; +create persistent entity Tmp.Header ( Name: String(100) ); +create persistent entity Tmp.Line ( Qty: Integer ); +create association Tmp.Line_Header from Tmp.Line to Tmp.Header; +create association Tmp.Line_Tags from Tmp.Line to Tmp.Header type ReferenceSet; +create association Tmp.Line_Main from Tmp.Line to Tmp.Header owner Both; +create microflow Tmp.Sub ($line: Tmp.Line) returns Boolean as $b +begin + declare $b Boolean = true; + return $b; +end; +create microflow Tmp.SubList ($lines: list of Tmp.Line) returns Boolean as $b +begin + declare $b Boolean = true; + return $b; +end; +` + +func assocListErrors(t *testing.T, flow string) []string { + t.Helper() + ctx, _ := newMockCtx(t) + prog := parseMDL(t, assocListSetup+flow) + sc := newScriptContext() + sc.collectDefinitions(prog) + var out []string + for _, err := range validateAssociationRetrieveListUse(ctx, prog, sc) { + out = append(out, err.Error()) + } + return out +} + +func TestAssociationRetrieveListUsedAsObject(t *testing.T) { + for _, tc := range []struct { + name string + flow string + want string // "" = no error + }{ + {"member path on the list (CE0117)", ` +create microflow Tmp.H ($h: Tmp.Header) returns Integer as $n +begin + retrieve $L from $h/Tmp.Line_Header; + declare $n Integer = $L/Qty; + return $n; +end;`, "member path `$L/…`"}, + {"change over the list (CE7247)", ` +create microflow Tmp.C ($h: Tmp.Header) +begin + retrieve $L from $h/Tmp.Line_Header; + change $L (Qty = 5); +end;`, "`change $L`"}, + {"the list passed to an object parameter (CE0117)", ` +create microflow Tmp.P ($h: Tmp.Header) returns Boolean as $b +begin + retrieve $L from $h/Tmp.Line_Header; + $b = call microflow Tmp.Sub(line = $L); + return $b; +end;`, "parameter line of Tmp.Sub"}, + {"a ReferenceSet is a list from either end", ` +create microflow Tmp.R ($l: Tmp.Line) returns Boolean as $b +begin + retrieve $T from $l/Tmp.Line_Tags; + declare $b Boolean = $T/Name != empty; + return $b; +end;`, "member path `$T/…`"}, + {"the start variable is a loop iterator", ` +create microflow Tmp.LoopIt ($hs: list of Tmp.Header) +begin + loop $h in $hs begin + retrieve $L from $h/Tmp.Line_Header; + change $L (Qty = 1); + end loop; +end;`, "`change $L`"}, + + // Controls: the shapes that build. + {"control: the bare list retrieve (clean on 11.6.6 and 11.15)", ` +create microflow Tmp.F ($h: Tmp.Header) returns Boolean as $b +begin + retrieve $L from $h/Tmp.Line_Header; + declare $b Boolean = true; + return $b; +end;`, ""}, + {"control: the list used as a list", ` +create microflow Tmp.G ($h: Tmp.Header) returns Integer as $n +begin + retrieve $L from $h/Tmp.Line_Header; + $n = count($L); + $b = call microflow Tmp.SubList(lines = $L); + loop $x in $L begin + change $x (Qty = 1); + end loop; + return $n; +end;`, ""}, + {"control: from the FROM side is one object", ` +create microflow Tmp.K ($l: Tmp.Line) returns Boolean as $b +begin + retrieve $H from $l/Tmp.Line_Header; + declare $b Boolean = $H/Name != empty; + return $b; +end;`, ""}, + {"control: owner Both is one-to-one, one object from the TO side", ` +create microflow Tmp.B ($h: Tmp.Header) returns Integer as $n +begin + retrieve $L from $h/Tmp.Line_Main; + declare $n Integer = $L/Qty; + return $n; +end;`, ""}, + {"control: a rebinding clears the list", ` +create microflow Tmp.Re ($h: Tmp.Header) returns Integer as $n +begin + retrieve $L from $h/Tmp.Line_Header; + retrieve $L from Tmp.Line first; + declare $n Integer = $L/Qty; + return $n; +end;`, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + errs := assocListErrors(t, tc.flow) + if tc.want == "" { + if len(errs) != 0 { + t.Fatalf("want no error, got %v", errs) + } + return + } + if len(errs) != 1 { + t.Fatalf("want 1 error containing %q, got %d: %v", tc.want, len(errs), errs) + } + for _, w := range []string{tc.want, "MDL-RETRIEVE03", "list of Tmp."} { + if !strings.Contains(errs[0], w) { + t.Errorf("error should mention %q:\n%s", w, errs[0]) + } + } + }) + } +} + +// The builder and the check must agree on which retrieves are lists: the check +// exists to describe what exec writes. +func TestAssociationRetrieveYieldsList(t *testing.T) { + for _, tc := range []struct { + refSet, ownerBoth, fromTo, want bool + }{ + {false, false, false, false}, // Reference from FROM: object + {false, false, true, true}, // Reference from TO: list + {false, true, false, false}, // owner Both: one-to-one + {false, true, true, false}, + {true, false, false, true}, // ReferenceSet: list either way + {true, false, true, true}, + {true, true, false, true}, + {true, true, true, true}, + } { + if got := associationRetrieveYieldsList(tc.refSet, tc.ownerBoth, tc.fromTo); got != tc.want { + t.Errorf("refSet=%v ownerBoth=%v fromTo=%v: got %v, want %v", tc.refSet, tc.ownerBoth, tc.fromTo, got, tc.want) + } + } +} diff --git a/mdl/executor/validate_void_code_calls.go b/mdl/executor/validate_void_code_calls.go index dc128cea82..ca00923749 100644 --- a/mdl/executor/validate_void_code_calls.go +++ b/mdl/executor/validate_void_code_calls.go @@ -63,6 +63,12 @@ type voidCodeActions struct { unknownIsVoid bool // shared, when set, carries project resolutions across runs (the editor). shared *CodeActionCache + + // declared types: which qualified names are enumerations and which are + // entities, for MDL043's wording (validate_declare_enum.go). The script's + // own come from the program; the project's are read once, on first ask. + scriptKinds map[string]declaredTypeKind + projectKinds map[string]declaredTypeKind } // voidness is what the resolver knows about one action. @@ -109,6 +115,10 @@ func newVoidCodeActions(prog *ast.Program, open func() backend.FullBackend) *voi r.script[flowKey(false, s.Name.String())] = s.ReturnType == nil || s.ReturnType.Type.Kind == ast.TypeVoid case *ast.CreateNanoflowStmt: r.script[flowKey(true, s.Name.String())] = s.ReturnType == nil || s.ReturnType.Type.Kind == ast.TypeVoid + case *ast.CreateEnumerationStmt: + r.recordScriptKind(s.Name.String(), declKindEnumeration) + case *ast.CreateEntityStmt: + r.recordScriptKind(s.Name.String(), declKindEntity) } } return r From 73c5ed87af511a5c2d0e763459ac23632246c755 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:42:44 +0000 Subject: [PATCH 24/25] fix(catalog): record every document a widget names, not the smallest of each kind (mendixlabs/mxcli#1513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanWidgetOwnRefs collected every Microflow/Nanoflow/Entity/Form value in a widget's own BSON but returned only the lexicographically smallest of each, and the refs table is projected from that single widgets_data column. A Data grid 2 (or gallery, list view) with `datasource: microflow Tmp.Rows` and `onClick: call microflow Tmp.Open(…)` therefore recorded only Tmp.Open: Tmp.Rows had no inbound reference, `show callers` found none, and QUAL004 said "not called from anywhere. Remove if unused" about a live datasource. The comment claiming page-level dedup made the choice immaterial was wrong. scanWidgetAllRefs now returns every name (sorted, deduped); rawWidgetInfo.AllRefs carries them, buildPages/buildSnippets collect the names beyond the single column onto the Builder, and buildReferences inserts them after the widget projections with WHERE NOT EXISTS so a page still gets one edge per target. The single columns keep their choice (the smallest) for `show widgets` and Starlark's microflow_ref; the lint-rules skill now says the column is one name and to ask refs_to() instead. Datasource microflows keep RefKind `action`: `datasource` is not in show callers' callerRefKinds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...6-widget-second-microflow-ref-dropped.json | 1 + .../mendix/write-lint-rules/catalog-tables.md | 2 +- .../1513-widget-datasource-and-click-refs.mdl | 64 +++++++++ mdl/catalog/builder.go | 6 + mdl/catalog/builder_pages.go | 103 ++++++++++--- mdl/catalog/builder_references.go | 20 +++ mdl/catalog/widget_multi_refs_test.go | 135 ++++++++++++++++++ 7 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-16-widget-second-microflow-ref-dropped.json create mode 100644 mdl-examples/bug-tests/1513-widget-datasource-and-click-refs.mdl create mode 100644 mdl/catalog/widget_multi_refs_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-16-widget-second-microflow-ref-dropped.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-16-widget-second-microflow-ref-dropped.json new file mode 100644 index 0000000000..8dc6c0ea27 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-16-widget-second-microflow-ref-dropped.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-10-16", "symptom": "QUAL004 \"Microflow 'Rows' is not called from anywhere. Remove if unused\" for the datasource microflow of a Data grid 2 (or gallery) that also has an `onClick: call microflow …`; `show callers of` the datasource microflow reports none. The on-click microflow is fine — whichever of the two sorts first survives", "cause": "`scanWidgetOwnRefs` collected every Microflow/Nanoflow/Entity/Form value in the widget's own BSON but returned only the lexicographically smallest of each, and the refs table is a SQL projection of that single widgets_data column. Its comment claimed page-level dedup made the per-widget choice immaterial — true only when another widget on the page happens to name the dropped one", "file": "`mdl/catalog/builder_pages.go` (`scanWidgetAllRefs`, `collectWidgetExtraRefs`, `rawWidgetInfo.AllRefs`), `mdl/catalog/builder_references.go` (extra-refs insert after the widget projections)", "insight": "Third instance of the widget-ref class after #773 (`Form` not collected) and #940 (templates absorbed): a single-valued column feeding a many-valued graph loses edges silently. Keep the single column for `show widgets` / Starlark `microflow_ref` back-compat (documented as one name, the smallest) and emit the rest from the Builder, deduped with INSERT … WHERE NOT EXISTS so the one-edge-per-page shape holds. Kept RefKind `action` for a datasource microflow: `datasource` would satisfy QUAL004 but is not in `show callers`' callerRefKinds. When re-measuring with `mxcli lint`, the catalog cache is keyed on the .mpr mtime, not the mxcli build — a catalog built by the unfixed binary is reused by the fixed one, so `refresh catalog full force` first or the fix looks ineffective.", "refs": ["mendixlabs/mxcli#1513", "mendixlabs/mxcli#773", "mendixlabs/mxcli#940"]} diff --git a/.claude/skills/mendix/write-lint-rules/catalog-tables.md b/.claude/skills/mendix/write-lint-rules/catalog-tables.md index 15f0439007..8fbc175f81 100644 --- a/.claude/skills/mendix/write-lint-rules/catalog-tables.md +++ b/.claude/skills/mendix/write-lint-rules/catalog-tables.md @@ -127,7 +127,7 @@ Returned by `widgets()` (full catalog — auto-detected). | `module_name` | string | `"Sales"` | | `entity_ref` | string | Referenced entity qualified name | | `attribute_ref` | string | Referenced attribute path | -| `microflow_ref` | string | Action/datasource microflow qualified name (e.g. a microflow-datasource ListView), else `""` | +| `microflow_ref` | string | Action/datasource microflow qualified name (e.g. a microflow-datasource ListView), else `""`. **One name only**: a widget that names several — a data grid 2 with a microflow datasource *and* an on-click microflow — shows the alphabetically smallest. Every one is in `refs` (kind `action`), so ask `refs_to(name)` whether a microflow is used, never this column. The same holds for `entity_ref`, `nanoflow_ref` and `page_ref` | | `nanoflow_ref` | string | Action/datasource nanoflow qualified name, else `""` | | `page_ref` | string | The page the widget's action opens (show page, create object then open page), else `""` | | `parent_widget_id` | string | `id` of the nearest catalogued ancestor widget; `""` at the page or snippet root. Wrappers the catalog skips (the synthetic `conditionalVisibilityWidget…` container) and non-widget holders (layout grid rows and columns, tab pages, a pluggable widget's properties and object-list items) are transparent: a widget in a layout grid column or a data grid 2 column has the grid as its parent | diff --git a/mdl-examples/bug-tests/1513-widget-datasource-and-click-refs.mdl b/mdl-examples/bug-tests/1513-widget-datasource-and-click-refs.mdl new file mode 100644 index 0000000000..5e4f9ac803 --- /dev/null +++ b/mdl-examples/bug-tests/1513-widget-datasource-and-click-refs.mdl @@ -0,0 +1,64 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1513 - QUAL004 flags a Data grid 2 datasource +-- microflow as "not called from anywhere" +-- ============================================================================ +-- +-- Symptom: +-- A widget that names two microflows in its own BSON — a Data grid 2 (or +-- gallery, list view) with `datasource: microflow …` and an `onClick: call +-- microflow …` — recorded only the alphabetically smaller one in the catalog. +-- The other got no refs row, so `mxcli lint` reported QUAL004 "is not called +-- from anywhere. Remove if unused" for a live microflow, and `show callers` +-- found nothing. Here Tmp1513.Open sorts before Tmp1513.Rows, so the +-- datasource microflow was the one dropped. +-- +-- After fix: +-- `refresh catalog full` then `mxcli lint` reports no QUAL004 for +-- Tmp1513.Rows, Tmp1513.Open, Tmp1513.GalleryRows or Tmp1513.GalleryOpen, and +-- `show callers of Tmp1513.Rows` lists Tmp1513.Overview. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1513-widget-datasource-and-click-refs.mdl -p app.mpr +-- mxcli lint -p app.mpr +-- ============================================================================ + +create module Tmp1513; + +create persistent entity Tmp1513.Item (Name: string(100)); + +create microflow Tmp1513.Rows () +returns list of Tmp1513.Item as $items +begin + retrieve $items from Tmp1513.Item; + return $items; +end; + +create microflow Tmp1513.Open ($Item: Tmp1513.Item) +begin +end; + +create microflow Tmp1513.GalleryRows () +returns list of Tmp1513.Item as $items +begin + retrieve $items from Tmp1513.Item; + return $items; +end; + +create microflow Tmp1513.GalleryOpen ($Item: Tmp1513.Item) +begin +end; + +create or modify page Tmp1513.Overview ( + Title: 'Overview', + Layout: Atlas_Core.Atlas_Default +) { + datagrid gItems (datasource: microflow Tmp1513.Rows, Selection: None, onClick: call microflow Tmp1513.Open(Item = $currentObject)) { + column (attribute: Name, caption: 'Name') + } + gallery galItems (datasource: microflow Tmp1513.GalleryRows, Selection: None, onClick: call microflow Tmp1513.GalleryOpen(Item = $currentObject)) { + template { + dynamictext txtName (content: '{1}', contentparams: ({1} = Name)) + } + } +}; diff --git a/mdl/catalog/builder.go b/mdl/catalog/builder.go index a6ff64162e..6ed204bb58 100644 --- a/mdl/catalog/builder.go +++ b/mdl/catalog/builder.go @@ -121,6 +121,12 @@ type Builder struct { // cataloguing the handlers and emitted by buildReferences. eventHandlerRefs []eventHandlerRef + // Page/snippet → document edges a widget makes beyond its single + // widgets_data columns (a data grid 2's datasource AND on-click + // microflow), collected by buildPages/buildSnippets and emitted by + // buildReferences (mendixlabs/mxcli#1513). + widgetExtraRefs []widgetExtraRef + // Built-in widget definitions supplied by the caller — used to populate // the widget_definitions catalog table alongside project widgets/. builtinWidgetMetas []WidgetDefinitionMeta diff --git a/mdl/catalog/builder_pages.go b/mdl/catalog/builder_pages.go index e478174a3d..6b057b0934 100644 --- a/mdl/catalog/builder_pages.go +++ b/mdl/catalog/builder_pages.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/base64" "fmt" + "sort" "strings" "github.com/mendixlabs/mxcli/sdk/pages" @@ -125,6 +126,7 @@ func (b *Builder) buildPages() error { return fmt.Errorf("insert widget %s for page %s: %w", w.Name, qualifiedName, err) } widgetCount++ + b.collectWidgetExtraRefs("PAGE", qualifiedName, moduleName, w) } } } @@ -219,6 +221,7 @@ func (b *Builder) buildSnippets() error { return fmt.Errorf("insert widget %s for snippet %s: %w", w.Name, qualifiedName, err) } widgetCount++ + b.collectWidgetExtraRefs("SNIPPET", qualifiedName, moduleName, w) } } } @@ -302,6 +305,13 @@ type rawWidgetInfo struct { NanoflowRef string // action/datasource nanoflow PageRef string // action page (Forms$PageSettings.Form) — see scanWidgetOwnRefs + // AllRefs is every document the widget names in its own BSON. The four + // single columns above hold one of each kind (the smallest, see + // scanWidgetOwnRefs) for `show widgets` and Starlark; a widget can name + // more — a data grid 2 with a microflow datasource AND an on-click + // microflow — and the refs table needs all of them (mendixlabs/mxcli#1513). + AllRefs widgetOwnRefs + // Tree position (mendixlabs/mxcli#1268). ParentID is the nearest INDEXED // ancestor, so a wrapper the walk skips (the synthetic // conditionalVisibilityWidget DivContainer) and the non-widget holders in @@ -385,12 +395,37 @@ var widgetChildKeys = map[string]bool{ "Templates": true, } -// scanWidgetOwnRefs collects the entity/microflow/nanoflow a widget references in -// its own (non-child-widget) BSON — datasource entity, on-click microflow, etc. -// Returns the lexicographically smallest match of each kind so the result is -// deterministic regardless of BSON map iteration order; page-level dedup in the -// refs projection makes the per-widget choice immaterial to the final graph. +// widgetOwnRefs is every entity, microflow, nanoflow and page a widget names in +// its own (non-child-widget) BSON, each list sorted and without duplicates. +type widgetOwnRefs struct { + Entities, Microflows, Nanoflows, Pages []string +} + +// scanWidgetOwnRefs returns ONE entity/microflow/nanoflow/page per kind from a +// widget's own BSON: the lexicographically smallest, so the result is +// deterministic regardless of BSON map iteration order. This is what the single +// widgets_data columns (EntityRef, MicroflowRef, NanoflowRef, PageRef) show — +// `show widgets` and Starlark's microflow_ref. It is NOT complete: a widget can +// name two microflows (a datasource and an on-click), and the one not picked +// here would have no reference at all if the refs table were built from these +// columns alone — QUAL004 then called a live datasource microflow unused +// (mendixlabs/mxcli#1513). The refs table takes every name, from +// scanWidgetAllRefs. func scanWidgetOwnRefs(w map[string]any) (entity, microflow, nanoflow, page string) { + all := scanWidgetAllRefs(w) + first := func(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] + } + return first(all.Entities), first(all.Microflows), first(all.Nanoflows), first(all.Pages) +} + +// scanWidgetAllRefs collects every entity/microflow/nanoflow/page a widget +// references in its own (non-child-widget) BSON — datasource entity, datasource +// microflow, on-click microflow, etc. +func scanWidgetAllRefs(w map[string]any) widgetOwnRefs { var ents, mfs, nfs, forms []string var walk func(v any) walk = func(v any) { @@ -426,19 +461,27 @@ func scanWidgetOwnRefs(w map[string]any) (entity, microflow, nanoflow, page stri } } walk(w) - min := func(xs []string) string { - if len(xs) == 0 { - return "" - } - m := xs[0] - for _, s := range xs[1:] { - if s < m { - m = s - } + return widgetOwnRefs{ + Entities: sortedUnique(ents), + Microflows: sortedUnique(mfs), + Nanoflows: sortedUnique(nfs), + Pages: sortedUnique(forms), + } +} + +// sortedUnique sorts xs and drops repeats; nil stays nil. +func sortedUnique(xs []string) []string { + if len(xs) == 0 { + return nil + } + sort.Strings(xs) + out := xs[:1] + for _, s := range xs[1:] { + if s != out[len(out)-1] { + out = append(out, s) } - return m } - return min(ents), min(mfs), min(nfs), min(forms) + return out } // extractLayoutRef extracts the layout reference from raw page BSON. Regular @@ -563,6 +606,7 @@ func walkWidget(w map[string]any, parentID string, depth int) []rawWidgetInfo { // Extract datasource entity + action microflow/nanoflow references from this // widget's own content (not its child widgets). widget.EntityRef, widget.MicroflowRef, widget.NanoflowRef, widget.PageRef = scanWidgetOwnRefs(w) + widget.AllRefs = scanWidgetAllRefs(w) widget.Class, widget.Style, widget.DynamicClasses = widgetAppearance(w) widget.ActionType, widget.HasConfirmation = widgetPrimaryAction(w) widget.ParentID, widget.Depth = parentID, depth @@ -919,3 +963,30 @@ func (b *Builder) buildLayouts() error { b.report("Layouts", count) return nil } + +// widgetExtraRef is a page/snippet → document edge a widget makes that its +// single widgets_data column does not carry (see rawWidgetInfo.AllRefs). +type widgetExtraRef struct { + containerType, containerQN, moduleName string + targetType, targetName, refKind string +} + +// collectWidgetExtraRefs records, for buildReferences, every name in w.AllRefs +// that is not already the widget's single column for that kind — those are +// projected from widgets_data directly. +func (b *Builder) collectWidgetExtraRefs(containerType, containerQN, moduleName string, w rawWidgetInfo) { + add := func(names []string, primary, targetType, refKind string) { + for _, n := range names { + if n == primary { + continue + } + b.widgetExtraRefs = append(b.widgetExtraRefs, widgetExtraRef{ + containerType, containerQN, moduleName, targetType, n, refKind, + }) + } + } + add(w.AllRefs.Entities, w.EntityRef, RefObjectEntity, RefKindDatasource) + add(w.AllRefs.Microflows, w.MicroflowRef, RefObjectMicroflow, RefKindAction) + add(w.AllRefs.Nanoflows, w.NanoflowRef, RefObjectNanoflow, RefKindAction) + add(w.AllRefs.Pages, w.PageRef, RefObjectPage, RefKindShowPage) +} diff --git a/mdl/catalog/builder_references.go b/mdl/catalog/builder_references.go index 99d278335f..adade8b909 100644 --- a/mdl/catalog/builder_references.go +++ b/mdl/catalog/builder_references.go @@ -635,6 +635,26 @@ func (b *Builder) buildReferences() error { } } + // The names a widget makes beyond its single column — the second + // microflow of a data grid 2 with a datasource and an on-click. Without + // them the datasource microflow had no inbound reference and QUAL004 + // said "not called from anywhere" (mendixlabs/mxcli#1513). NOT EXISTS + // keeps the projection's one-edge-per-page shape when another widget's + // column already made the edge. + for _, r := range b.widgetExtraRefs { + res, xerr := b.tx.Exec( + `INSERT INTO refs (SourceType, SourceId, SourceName, TargetType, TargetId, TargetName, RefKind, ModuleName, ProjectId, SnapshotId) + SELECT ?, '', ?, ?, '', ?, ?, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM refs WHERE SourceType = ? AND SourceName = ? AND TargetType = ? AND TargetName = ? AND RefKind = ? AND SnapshotId = ?)`, + r.containerType, r.containerQN, r.targetType, r.targetName, r.refKind, r.moduleName, projectID, snapshotID, + r.containerType, r.containerQN, r.targetType, r.targetName, r.refKind, snapshotID) + if xerr == nil { + if n, e := res.RowsAffected(); e == nil { + refCount += int(n) + } + } + } + // Page/snippet -> widget definition. buildWidgetDefinitions runs before // this pass, so the join target is populated. if n, werr := insertWidgetRefs(b.tx, projectID, snapshotID); werr == nil { diff --git a/mdl/catalog/widget_multi_refs_test.go b/mdl/catalog/widget_multi_refs_test.go new file mode 100644 index 0000000000..b88a341956 --- /dev/null +++ b/mdl/catalog/widget_multi_refs_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1513: a widget that names two microflows in its own BSON — +// a Data grid 2 with `datasource: microflow Tmp.Rows` and `onClick: call +// microflow Tmp.Open(…)` — recorded only the lexicographically smallest one. +// The refs projection reads the widget's single MicroflowRef column, so the +// other microflow had no inbound reference: QUAL004 reported the datasource +// microflow "not called from anywhere. Remove if unused", and `show callers` +// found nothing. Same for a gallery or list view with a datasource and a click. +package catalog + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// dataGrid2 is a Data grid 2 as stored: a microflow datasource and an on-click +// microflow, both inside the widget's own Object, plus a column header widget +// (a child, whose refs must stay its own). +func dataGrid2(name, datasourceMF, onClickMF string) map[string]any { + return map[string]any{ + "$Type": "CustomWidgets$CustomWidget", + "$ID": "id-" + name, + "Name": name, + "Type": map[string]any{"WidgetId": "com.mendix.widget.web.datagrid.Datagrid"}, + "Object": map[string]any{ + "Properties": []any{int32(2), + map[string]any{"Value": map[string]any{ + "DataSource": map[string]any{ + "$Type": "Forms$MicroflowSource", + "MicroflowSettings": map[string]any{"Microflow": datasourceMF}, + }, + }}, + map[string]any{"Value": map[string]any{ + "Action": map[string]any{ + "$Type": "Forms$MicroflowAction", + "MicroflowSettings": map[string]any{"Microflow": onClickMF}, + }, + }}, + }, + }, + } +} + +func TestScanWidgetAllRefsKeepsEveryMicroflow(t *testing.T) { + // Open sorts before Rows, so the datasource is the one the old scan dropped. + w := dataGrid2("dg", "Tmp.Rows", "Tmp.Open") + got := scanWidgetAllRefs(w) + if want := []string{"Tmp.Open", "Tmp.Rows"}; !reflect.DeepEqual(got.Microflows, want) { + t.Errorf("microflows = %v, want %v", got.Microflows, want) + } + // The single column keeps its documented choice — the smallest — for + // `show widgets` and Starlark's microflow_ref. + if _, mf, _, _ := scanWidgetOwnRefs(w); mf != "Tmp.Open" { + t.Errorf("single MicroflowRef = %q, want Tmp.Open (the smallest)", mf) + } + // Duplicates collapse. + if got := scanWidgetAllRefs(dataGrid2("dg", "Tmp.Same", "Tmp.Same")); !reflect.DeepEqual(got.Microflows, []string{"Tmp.Same"}) { + t.Errorf("duplicate microflow not collapsed: %v", got.Microflows) + } +} + +// End to end through the builder: buildPages indexes the widget, buildReferences +// projects the refs. Both microflows must be reachable from the page, once each. +func TestWidgetWithDatasourceAndClickRefsBothMicroflows(t *testing.T) { + const modID = model.ID("mod-tmp") + pg := &pages.Page{Name: "Overview"} + pg.ID = "pg-1" + pg.ContainerID = modID + + raw := map[string]any{ + "$Type": "Forms$Page", + "FormCall": map[string]any{ + "Arguments": []any{int32(2), map[string]any{ + "Widgets": []any{int32(2), + dataGrid2("dgItems", "Tmp.Rows", "Tmp.Open"), + // A second widget whose primary pick is Tmp.Rows: the edge + // must still be recorded once, not twice. + dataGrid2("dgOther", "Tmp.Rows", "Tmp.Zeta"), + }, + }}, + }, + } + + cat, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cat.Close() }) + tx, err := cat.CatalogDB().Begin() + if err != nil { + t.Fatal(err) + } + b := &Builder{ + catalog: cat, + reader: &mock.MockBackend{ + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{pg}, nil }, + GetRawUnitFunc: func(model.ID) (map[string]any, error) { return raw, nil }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{}, nil + }, + }, + snapshot: &Snapshot{ID: "snap-1"}, + hierarchy: &hierarchy{ + moduleIDs: map[model.ID]bool{modID: true}, + moduleNames: map[model.ID]string{modID: "Tmp"}, + containerParent: map[model.ID]model.ID{}, + folderNames: map[model.ID]string{}, + }, + tx: tx, + fullMode: true, + } + if err := b.buildPages(); err != nil { + t.Fatalf("buildPages: %v", err) + } + if err := b.buildReferences(); err != nil { + t.Fatalf("buildReferences: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + + for _, mf := range []string{"Tmp.Rows", "Tmp.Open", "Tmp.Zeta"} { + got := refsTo(t, cat, mf) + want := refRow{"PAGE", "Tmp.Overview", "MICROFLOW", mf, "action"} + if got[want] != 1 || len(got) != 1 { + t.Errorf("refs to %s = %v, want exactly one %v", mf, got, want) + } + } +} From 5e3dda6c60a6320ff2b34b3808625290a3ec5771 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:48:04 +0000 Subject: [PATCH 25/25] fix(settings): show the project id in describe/show settings (mendixlabs/mxcli#1526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project id — the $ID of the Projects$Project root unit every module points to, which Business Events uses as the app id — was shown by no command, so two apps made from one copied model could only be found to share it by grepping mprcontents. The model-engine backend's GetProjectRootID was still the generated "unimplemented" stub; only CreateModule reached the reader's method directly. Backend.GetProjectRootID now delegates to the reader (struck off the unreachable-unimplemented allowlist). describe settings prints it as a `-- Project ID: …` comment, so the output still executes, and show settings (table and JSON) adds a Project row. Read-only: no re-ID command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .../2026-10-16-settings-show-project-id.json | 1 + .../skills/mendix/project-settings/SKILL.md | 12 +++-- .../settings/list-describe-settings.md | 2 + mdl/backend/modelsdk/backend.go | 10 ++++ mdl/backend/modelsdk/project_root_id_test.go | 49 +++++++++++++++++ .../unimplemented_reachability_test.go | 1 - mdl/executor/cmd_settings.go | 22 ++++++++ mdl/executor/cmd_settings_mock_test.go | 53 +++++++++++++++++++ 8 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-16-settings-show-project-id.json create mode 100644 mdl/backend/modelsdk/project_root_id_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-settings-show-project-id.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-settings-show-project-id.json new file mode 100644 index 0000000000..69ea20223c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-settings-show-project-id.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "No command showed the project id (the Projects$Project root unit $ID, which Business Events uses as the app id), so two apps made from one copied .mpr could only be found to share it by grepping mprcontents", "cause": "`describe settings` / `show settings` never read it, and the model-engine backend's GetProjectRootID was the generated `unimplemented` stub (only CreateModule reached the reader's method directly), so nothing on the backend interface could return it", "file": "mdl/backend/modelsdk/backend.go (GetProjectRootID); mdl/executor/cmd_settings.go (projectID, a `-- Project ID:` comment in describeSettings, a `Project` row in listSettings)", "insight": "A generated `unimplemented` stub on the backend hides a capability the reader already has: the interface looks complete and the call errors only at runtime, so check the stub list before concluding a value is unreadable. Describe output must stay executable, so a value with no `alter` spelling goes in a comment. The id read back matches the Projects$Project .mxunit file name in the fixture (bf10dffa-… on expr-checker), and the backend test pins it as every module unit's ContainerID. Not done: re-ID on copy / `mxcli new` (maintainer decision: read-only)", "refs": ["mendixlabs/mxcli#1526"]} diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index 4689b85da8..56d55aa836 100644 --- a/.claude/skills/mendix/project-settings/SKILL.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -11,10 +11,10 @@ description: "Read and change project settings in MDL — database configuration | lines | section | |---|---| | 22-31 | When to Use This Skill | -| 32-304 | Commands | -| 305-332 | Common Patterns | -| 333-341 | Checklist | -| 342-346 | AfterStartupMicroflow Must Return Boolean | +| 32-308 | Commands | +| 309-336 | Common Patterns | +| 337-345 | Checklist | +| 346-350 | AfterStartupMicroflow Must Return Boolean | # Project Settings @@ -102,6 +102,10 @@ parses the bare major and rejects the project outright with `describe settings` always emits the project's own spelling, so its output replays cleanly. +**The project ID is read-only.** `describe settings` prints it as a `-- Project ID:` +comment and `show settings` as a `Project` row: the `Projects$Project` unit `$ID`, which +Business Events uses as the app id. A copied `.mpr` shares it; no MDL statement changes it. + **`describe settings` never prints `DatabasePassword`.** Describe output is what gets committed and reviewed, so the password is omitted and a comment says it is set. Replaying the output leaves the stored password unchanged (`create or modify diff --git a/docs-site/src/reference/settings/list-describe-settings.md b/docs-site/src/reference/settings/list-describe-settings.md index e9b24892df..6ccd71a8ff 100644 --- a/docs-site/src/reference/settings/list-describe-settings.md +++ b/docs-site/src/reference/settings/list-describe-settings.md @@ -12,6 +12,8 @@ Displays project settings. `LIST SETTINGS` provides a compact overview of all se When `DESCRIBE SETTINGS` is called without a category, it outputs all settings. When a category is specified, only that category is shown. +Both also show the **project ID** — the `$ID` of the `Projects$Project` root unit that every module belongs to. `LIST SETTINGS` prints it as a `Project` row; `DESCRIBE SETTINGS` prints it as a `-- Project ID: …` comment, so the output still executes. It is read-only: there is no statement that changes it. Business Events uses it as the app id, so two apps made from one model (a copied `.mpr`, a shared template) report the same project ID. + The available settings categories are: | Category | Contents | diff --git a/mdl/backend/modelsdk/backend.go b/mdl/backend/modelsdk/backend.go index c589ffc595..ad844d0e24 100644 --- a/mdl/backend/modelsdk/backend.go +++ b/mdl/backend/modelsdk/backend.go @@ -235,6 +235,16 @@ func (b *Backend) ProjectVersion() *types.ProjectVersion { } } +// GetProjectRootID returns the $ID of the Projects$Project root unit — the +// container every module points to, and the value Business Events uses as the +// app id (mendixlabs/mxcli#1526). A copy of the model shares it. +func (b *Backend) GetProjectRootID() (string, error) { + if b.reader == nil { + return "", fmt.Errorf("GetProjectRootID: not connected") + } + return b.reader.GetProjectRootID() +} + func (b *Backend) GetMendixVersion() (string, error) { if b.reader == nil { return "", nil diff --git a/mdl/backend/modelsdk/project_root_id_test.go b/mdl/backend/modelsdk/project_root_id_test.go new file mode 100644 index 0000000000..2228198dac --- /dev/null +++ b/mdl/backend/modelsdk/project_root_id_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" +) + +// mendixlabs/mxcli#1526: the project id (the Projects$Project root unit's $ID, +// which Business Events uses as the app id) was readable by no command, because +// the backend's GetProjectRootID was the generated "unimplemented" stub. +func TestGetProjectRootID_IsTheModulesContainer(t *testing.T) { + b := New() + if err := b.Connect(copyFixture(t)); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + root, err := b.GetProjectRootID() + if err != nil { + t.Fatalf("GetProjectRootID: %v", err) + } + if len(root) != 36 { + t.Fatalf("project id = %q, want a 36-char UUID", root) + } + units, err := b.ListUnits() + if err != nil { + t.Fatalf("ListUnits: %v", err) + } + modules := 0 + for _, u := range units { + if u.Type != "Projects$ModuleImpl" { + continue + } + modules++ + if string(u.ContainerID) != root { + t.Errorf("module unit %s has ContainerID %s, want the project id %s", u.ID, u.ContainerID, root) + } + } + if modules == 0 { + t.Fatal("fixture listed no module units; the control has nothing to compare") + } +} + +func TestGetProjectRootID_NotConnected(t *testing.T) { + if _, err := New().GetProjectRootID(); err == nil { + t.Error("want an error when not connected") + } +} diff --git a/mdl/backend/modelsdk/unimplemented_reachability_test.go b/mdl/backend/modelsdk/unimplemented_reachability_test.go index aedf6294b8..d599a913e6 100644 --- a/mdl/backend/modelsdk/unimplemented_reachability_test.go +++ b/mdl/backend/modelsdk/unimplemented_reachability_test.go @@ -80,7 +80,6 @@ import ( // rather than adding a row to match the failure. var unreachableUnimplemented = map[string]string{ "FindAllCustomWidgetTypes": "BYPASS, accepted: reached only via the reader, inside modelsdk/mpr itself", - "GetProjectRootID": "BYPASS, accepted: callers hold a reader; this package uses b.reader.GetProjectRootID directly", "ListAllUnitIDs": "BYPASS, accepted: cmd/mxcli/diag.go holds a concrete reader; infrastructure_write.go uses b.reader", } diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index 73db42a39d..fd71b28171 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -30,6 +30,10 @@ func listSettings(ctx *ExecContext) error { Columns: []string{"Section", "Key Values"}, } + if id := projectID(ctx); id != "" { + tr.Rows = append(tr.Rows, []any{"Project", "ID: " + id + " (read-only; Business Events app id)"}) + } + if ps.Model != nil { ms := ps.Model values := []string{} @@ -102,6 +106,18 @@ func listSettings(ctx *ExecContext) error { return writeResult(ctx, tr) } +// projectID is the $ID of the Projects$Project root unit: the container every +// module points to, and the app id Business Events registers under — so two +// apps made from one model (a copied .mpr, a template) share it +// (mendixlabs/mxcli#1526). Read-only; empty when the backend cannot say. +func projectID(ctx *ExecContext) string { + id, err := ctx.Backend.GetProjectRootID() + if err != nil { + return "" + } + return id +} + // describeSettings outputs the full MDL description of all settings. // describeSettings prints the project settings as re-executable `alter settings` // statements. With configName set (DESCRIBE SETTINGS CONFIGURATION 'X') it @@ -121,6 +137,12 @@ func describeSettings(ctx *ExecContext, configName string) error { return describeSettingsConfiguration(ctx, ps, configName) } + // The project id has no `alter settings` spelling — it is the identity of + // the model — so it is printed as a comment and the output still executes. + if id := projectID(ctx); id != "" { + fmt.Fprintf(ctx.Output, "-- Project ID: %s (read-only; Business Events uses it as the app id, and a copy of the model shares it)\n\n", id) + } + // Model settings if ps.Model != nil { ms := ps.Model diff --git a/mdl/executor/cmd_settings_mock_test.go b/mdl/executor/cmd_settings_mock_test.go index be138f14ff..f6065b99ad 100644 --- a/mdl/executor/cmd_settings_mock_test.go +++ b/mdl/executor/cmd_settings_mock_test.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/backend/mock" @@ -91,3 +92,55 @@ func TestShowSettings_JSON(t *testing.T) { assertNoError(t, listSettings(ctx)) assertValidJSON(t, buf.String()) } + +// mendixlabs/mxcli#1526: the project id (Business Events' app id) is shown, +// read-only — as a comment in describe so the output still executes, and as a +// row in show settings. +const testProjectID = "d4dec528-09bd-44f4-8daa-36658e546848" + +func projectIDBackend() *mock.MockBackend { + return &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectRootIDFunc: func() (string, error) { return testProjectID, nil }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{Model: &model.ModelSettings{HashAlgorithm: "BCrypt", JavaVersion: "17"}}, nil + }, + } +} + +func TestDescribeSettings_ShowsProjectIDAsComment(t *testing.T) { + ctx, buf := newMockCtx(t, withBackend(projectIDBackend())) + assertNoError(t, describeSettings(ctx, "")) + out := buf.String() + assertContainsStr(t, out, "-- Project ID: "+testProjectID) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, testProjectID) && !strings.HasPrefix(line, "--") { + t.Errorf("the project id must only appear in a comment, got line %q", line) + } + } +} + +func TestShowSettings_ShowsProjectID(t *testing.T) { + ctx, buf := newMockCtx(t, withBackend(projectIDBackend())) + assertNoError(t, listSettings(ctx)) + assertContainsStr(t, buf.String(), testProjectID) + + ctx, buf = newMockCtx(t, withBackend(projectIDBackend()), withFormat(FormatJSON)) + assertNoError(t, listSettings(ctx)) + assertValidJSON(t, buf.String()) + assertContainsStr(t, buf.String(), testProjectID) +} + +// No project id available (a backend that cannot read it): nothing printed, +// nothing fails. +func TestSettings_NoProjectID(t *testing.T) { + mb := projectIDBackend() + mb.GetProjectRootIDFunc = func() (string, error) { return "", fmt.Errorf("unsupported") } + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeSettings(ctx, "")) + if strings.Contains(buf.String(), "Project ID") { + t.Errorf("no id, no line:\n%s", buf.String()) + } + ctx, _ = newMockCtx(t, withBackend(mb)) + assertNoError(t, listSettings(ctx)) +}