diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-call-microflow-output-untyped-member-checks-silent.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-call-microflow-output-untyped-member-checks-silent.json new file mode 100644 index 000000000..15f918e2b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-25-call-microflow-output-untyped-member-checks-silent.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-25", "symptom": "mendixlabs/mxcli#1654: `check -p --references` passed `$r = call microflow AddOn.GetFirst(); declare $s String = $r/Secret;` where Secret is a Hidden attribute of a Usable entity in a Protected (add-on) module; mx check refused it with CE1406. The same gap left `change $r (Bogus = …)` on a call result unchecked (no CE1613)", "cause": "walkTypedFlow typed the variables of parameters, create, retrieve and loop iterators, not the OutputVariable of a CallMicroflowStmt. A member path on an untyped variable resolves no entity, and pathMembers(\"\") / checkMembers(\"\") return nothing", "file": "mdl/executor/validate_member_refs.go (walkTypedFlow, storedMicroflowReturnEntity), validate_protected_module_members.go (checkFlowMembers)", "insight": "Every binder walkTypedFlow does not type is a silent hole in every check that shares it. A call result can be typed from the stored callee's ReturnType (ObjectType/ListType carry EntityQualifiedName), but only when the script does not itself create or rewrite that microflow: the stored type may be the one being replaced, and a stale type turns a valid member into a false refusal that blocks exec. Silence is the safe side there.", "refs": ["mendixlabs/mxcli#1654", "mendixlabs/mxcli#1652", "mendixlabs/mxcli#1498"], "ce": ["CE1406", "CE1613"], "rules": []} diff --git a/CHANGELOG.md b/CHANGELOG.md index b291e68f2..31661194b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check -p --references` knows the entity a `call microflow` returns** (mendixlabs/mxcli#1654) — `$r = call microflow AddOn.Request_GetFirst(); declare $s String = $r/Secret;` in an ordinary module passed `check` and `exec`, and mxbuild refused it with CE1406 because `Secret` is a Hidden attribute of a Protected module. The walk that types a flow's variables covered parameters, `create`, `retrieve` and loop iterators but not the output of `call microflow`, so every member check on a call result was silent — the CE1406 member check and the CE1613 check on `change $r (…)` alike. The output now holds the stored callee's return entity (an object, or a list of it, which also types a loop over it). A callee the same script creates or rewrites stays untyped, so its old stored type cannot cause a false refusal. - **A pluggable widget's text-template parameter across an association builds, and describes so it re-executes** (mendixlabs/mxcli#1494) — `fieldset fs (legend: '{1}', legendParams: ({1} = M.Parent_Child/Label))` passed `check` and `exec` and failed the build with `[CE1365] "Attribute 'M.Child.Label' is not an attribute of entity 'M.Parent'."`: the parameter was written with its attribute but without the association hop, so mxbuild read the attribute against the widget's own entity. The same writer serves an object-list item's template (an Accordion group's `headerText`) and a Data grid 2 column's dynamic text, and all three failed; on Mendix 11.12.4 they now build with 0 errors. `describe page` printed such a parameter as the bare attribute (`{1} = Label`), which re-executed into CE1613 "The selected attribute 'M.Parent.Label' no longer exists"; it now prints `M.Parent_Child/Label`. The companion report, an expression parameter (`{1} = if 1 = 1 then 'A' else 'B'`) stored as an attribute name (mendixlabs/mxcli#1495), was fixed in v0.25.0 (#823) and is now pinned by a test. - **`alter entity … add event handler` adds a second handler for the same event** (mendixlabs/mxcli#1540) — `add event handler on before commit call M.Second($currentObject)` failed with "event handler already exists for Before Commit" when the entity already had one, though Studio Pro allows several handlers for one event and `create or modify entity` with two `on before commit` clauses stores both (measured on 11.12.4: two Before Commit handlers are 0 errors, a void-returning one is CE7247, so the check does look at them). A handler is now identified by its moment, event and microflow: another microflow adds a second handler, the same microflow again is still a duplicate. The same key had made `add event handler if not exists … call M.New` skip — reporting "already exists" — whenever any handler for the event existed, and `drop event handler on before commit` remove whichever handler was stored first. `drop event handler` now takes `call M.Mf` to say which one; without it, a drop that matches several handlers is refused and names them. diff --git a/mdl/executor/validate_member_refs.go b/mdl/executor/validate_member_refs.go index 7cbe77485..57ab9ebc1 100644 --- a/mdl/executor/validate_member_refs.go +++ b/mdl/executor/validate_member_refs.go @@ -9,6 +9,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" ) // `check --references` resolved the DOCUMENT and the ENTITY a statement names, @@ -346,7 +347,8 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b flowQN string, params []ast.MicroflowParam, body []ast.MicroflowStatement) []string { var out []string - walkTypedFlow(ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) { + scriptFlows := func(qn string) bool { return sc != nil && sc.microflows[qn] } + walkTypedFlow(ctx, params, body, scriptFlows, func(st ast.MicroflowStatement, vars map[string]string) { switch s := st.(type) { case *ast.CreateObjectStmt: out = append(out, checkMembers(ctx, sc, authored, flowQN, @@ -373,8 +375,13 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b // (checkFlowMembers, the Protected-module member check): two copies of it would // drift on which statements type a variable, and a check that loses a variable // goes silent rather than wrong. +// +// scriptFlows names the microflows the script itself creates or rewrites: the +// output of a call to one of those is left untyped, because the stored return +// type may be the one the script is replacing, and a stale type would turn a +// valid member into a false refusal. func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.MicroflowStatement, - visit func(st ast.MicroflowStatement, vars map[string]string)) { + scriptFlows func(qn string) bool, visit func(st ast.MicroflowStatement, vars map[string]string)) { vars := map[string]string{} for _, p := range params { @@ -419,6 +426,15 @@ func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.Mic } } } + case *ast.CallMicroflowStmt: + // The output holds the callee's return entity (an object, or a + // list of it); without this a member read on a call result + // resolves no entity and every check on it is silent (#1654). + if s.OutputVariable != "" && !scriptFlows(s.MicroflowName.String()) { + if qn, ok := storedMicroflowReturnEntity(ctx, s.MicroflowName); ok { + vars[s.OutputVariable] = qn + } + } case *ast.IfStmt: walk(s.ThenBody) walk(s.ElseBody) @@ -654,3 +670,30 @@ func nearMembers(ctx *ExecContext, entityQN string) string { } return fmt.Sprintf(" — it has %s", strings.Join(names, ", ")) } + +// storedMicroflowReturnEntity is the entity a stored microflow returns, as an +// object or a list of it. ok is false for any other return type and for a +// microflow that resolves nowhere. +func storedMicroflowReturnEntity(ctx *ExecContext, qn ast.QualifiedName) (string, bool) { + h, err := getHierarchy(ctx) + if err != nil { + return "", false + } + mfs, err := microflowsNamed(ctx, qn.Name) + if err != nil { + return "", false + } + for _, mf := range mfs { + if h.GetModuleName(h.FindModuleID(mf.ContainerID)) != qn.Module { + continue + } + switch t := mf.ReturnType.(type) { + case *microflows.ObjectType: + return t.EntityQualifiedName, t.EntityQualifiedName != "" + case *microflows.ListType: + return t.EntityQualifiedName, t.EntityQualifiedName != "" + } + return "", false + } + return "", false +} diff --git a/mdl/executor/validate_member_refs_test.go b/mdl/executor/validate_member_refs_test.go index e4bea209e..e59dfd87a 100644 --- a/mdl/executor/validate_member_refs_test.go +++ b/mdl/executor/validate_member_refs_test.go @@ -11,6 +11,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/backend/mock" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" ) // `CHANGE $Order ("IsArchived" = true)` — no such attribute — passed @@ -373,6 +374,50 @@ func TestValidateMemberReferences_TypesALoopIterator(t *testing.T) { } } +// The output of `call microflow` holds the callee's return entity, so a member +// on it resolves like one on a parameter (mendixlabs/mxcli#1654). A list return +// types the list, so a LOOP over it types its iterator. A callee the script +// itself (re)creates is left untyped: its stored return type may be the one +// the script replaces. +func TestValidateMemberReferences_TypesACallMicroflowResult(t *testing.T) { + ctx := memberFixture(t) + shop, _ := ctx.Backend.GetModuleByName("Shop") + getOrder := mkMicroflow(shop.ID, "Order_Get") + getOrder.ReturnType = µflows.ObjectType{EntityQualifiedName: "Shop.Order"} + listOrders := mkMicroflow(shop.ID, "Order_List") + listOrders.ReturnType = µflows.ListType{EntityQualifiedName: "Shop.Order"} + ctx.Backend.(*mock.MockBackend).ListMicroflowsFunc = func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{getOrder, listOrders}, nil + } + call := func(out, name string) *ast.CallMicroflowStmt { + return &ast.CallMicroflowStmt{OutputVariable: out, MicroflowName: ast.QualifiedName{Module: "Shop", Name: name}} + } + + msgs := validateMemberReferences(ctx, flowProgram(nil, call("O", "Order_Get"), changeStmt("O", `"Bogus"`)), newScriptContext()) + if len(msgs) != 1 || !strings.Contains(msgs[0], "Shop.Order") || !strings.Contains(msgs[0], "Bogus") { + t.Errorf("a CHANGE on a call result went unchecked: %v", msgs) + } + + msgs = validateMemberReferences(ctx, flowProgram(nil, call("Orders", "Order_List"), + &ast.LoopStmt{LoopVariable: "O", ListVariable: "Orders", + Body: []ast.MicroflowStatement{changeStmt("O", `"Bogus"`)}}), newScriptContext()) + if len(msgs) != 1 || !strings.Contains(msgs[0], "Bogus") { + t.Errorf("a CHANGE on the iterator over a list call result went unchecked: %v", msgs) + } + + // CONTROL: a real member stays silent, so the entity is the callee's. + if msgs := validateMemberReferences(ctx, flowProgram(nil, call("O", "Order_Get"), changeStmt("O", `"OrderNo"`)), newScriptContext()); len(msgs) != 0 { + t.Errorf("a real member of the call result was refused: %v", msgs) + } + + // CONTROL: the script rewrites the callee, so its stored type is not trusted. + sc := newScriptContext() + sc.microflows["Shop.Order_Get"] = true + if msgs := validateMemberReferences(ctx, flowProgram(nil, call("O", "Order_Get"), changeStmt("O", `"Bogus"`)), sc); len(msgs) != 0 { + t.Errorf("typed a call result from the stored type of a microflow the script rewrites: %v", msgs) + } +} + // An association retrieve feeding a LOOP is the ordinary bulk-update shape, and // leaving it untyped made the check silent on the commonest place a member is // named. Both directions resolve, because a retrieve may traverse from either diff --git a/mdl/executor/validate_protected_module_members.go b/mdl/executor/validate_protected_module_members.go index 97f0fa5b6..dd56ad5a8 100644 --- a/mdl/executor/validate_protected_module_members.go +++ b/mdl/executor/validate_protected_module_members.go @@ -51,7 +51,8 @@ func (r *protectedRefResolver) checkFlowMembers(name ast.QualifiedName, params [ } } - walkTypedFlow(r.ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) { + scriptFlows := func(qn string) bool { _, defined := r.scriptFlows[qn]; return defined } + walkTypedFlow(r.ctx, params, body, scriptFlows, func(st ast.MicroflowStatement, vars map[string]string) { switch s := st.(type) { case *ast.CreateObjectStmt: who := "create " + s.EntityType.String() diff --git a/mdl/executor/validate_protected_module_refs_test.go b/mdl/executor/validate_protected_module_refs_test.go index bf5decf23..521de989d 100644 --- a/mdl/executor/validate_protected_module_refs_test.go +++ b/mdl/executor/validate_protected_module_refs_test.go @@ -42,6 +42,11 @@ func protectedModuleFixture(t *testing.T, settingsErr error) *ExecContext { mf(addOn, "API_MF", "API"), mf(open, "MF", "Hidden"), } + // A Usable microflow returning the Usable entity Pub, for the members of a + // call result (#1654). + getPub := mf(addOn, "API_GetPub", "API") + getPub.ReturnType = µflows.ObjectType{EntityQualifiedName: "MyAddOn.Pub"} + flows = append(flows, getPub) // Members, for the CE1406 member cases: Pub and Pub2 are Usable entities // with one Usable and one Hidden attribute each, joined by a Hidden and a // Usable association. Open.E holds a Hidden attribute too — the control. @@ -268,6 +273,15 @@ func TestValidateProtectedModuleRefs(t *testing.T) { "create microflow MyAddOn.Inner ($p: MyAddOn.Pub) begin change $p (Inner = 'x'); retrieve $q from $p/MyAddOn.Pub_Pub2; end;", nil}, {"open module hidden attribute", "create microflow MyFirstModule.C ($e: Open.E) begin change $e (Inner = 'x'); end;", nil}, {"untyped variable is silent", "create microflow MyFirstModule.C () begin change $nobody (Inner = 'x'); end;", nil}, + // --- a member of a call result (#1654) --- + {"hidden attribute on a call result", + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Inner; end;", + []string{"CE1406", "the end event is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"hidden attribute on a call result, in a declare", + "create microflow MyFirstModule.G () begin $p = call microflow MyAddOn.API_GetPub(); declare $s String = $p/Inner; end;", + []string{"CE1406", "declare $s is not allowed to use attribute 'MyAddOn.Pub.Inner'"}}, + {"usable attribute on a call result", + "create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Shown; end;", nil}, // --- controls: an ordinary module hides nothing --- {"open module page", "create or modify navigation Responsive home page Open.P;", nil},